@drakulavich/kesha-voice-kit 1.25.0 → 1.27.0-alpha.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
@@ -24,7 +24,7 @@
24
24
 
25
25
  ## Quick Start
26
26
 
27
- Runtime: **[Bun](https://bun.sh)** >= 1.3.0 · Platforms: macOS arm64, Linux x64. Windows x64 is currently blocked at install ([#216](https://github.com/drakulavich/kesha-voice-kit/issues/216)).
27
+ Runtime: **[Bun](https://bun.sh)** >= 1.3.0 · Platforms: macOS arm64, Linux x64, Windows x64. Linux and Windows run the ONNX engine — everything except microphone capture (`kesha record`), macOS system voices, speaker diarization, and text language detection, which need Apple frameworks.
28
28
 
29
29
  ```bash
30
30
  # 1. Install Bun (skip if you have it) — Linux & macOS:
@@ -36,7 +36,8 @@ curl -fsSL https://bun.sh/install | bash # or: brew install oven-sh/bun/b
36
36
  bun add -g @drakulavich/kesha-voice-kit
37
37
  kesha --version # confirms `kesha` resolved on PATH
38
38
  kesha install --plan # preview exact download/disk sizes first — downloads nothing
39
- kesha install # ~2.5 GB (engine + speech-to-text models), explicit — never automatic.
39
+ kesha install # ~2.5 GB on Linux/Windows; ~0.6 GB on Apple Silicon, whose CoreML
40
+ # engine uses a different, smaller model set. Explicit — never automatic.
40
41
  # No progress bar during the model step; can take several minutes.
41
42
  # Prefer a guided wizard? `kesha init` walks through the same choices interactively.
42
43
 
@@ -21,7 +21,7 @@ _kesha_completion() {
21
21
  completions) opts="--help -h" ;;
22
22
  doctor) opts="--help -h --json --redact" ;;
23
23
  init) opts="--help -h --coreml --onnx --no-cache --plan --yes --tts --vad --diarize" ;;
24
- install) opts="--help -h --coreml --onnx --no-cache --plan --tts --vad --diarize" ;;
24
+ install) opts="--help -h --coreml --onnx --no-cache --plan --tts --vad --diarize --engine-version" ;;
25
25
  logs) opts="--help -h --json" ;;
26
26
  manpage) opts="--help -h" ;;
27
27
  mcp) opts="--help -h" ;;
@@ -54,6 +54,7 @@ complete -c kesha -n '__fish_seen_subcommand_from install' -l plan -d 'Show down
54
54
  complete -c kesha -n '__fish_seen_subcommand_from install' -l tts -d 'Also install TTS models (English only; add codes for more, e.g. --tts en ru (preview sizes: kesha install --plan))'
55
55
  complete -c kesha -n '__fish_seen_subcommand_from install' -l vad -d 'Also install Silero VAD (~2.3MB) for long-audio preprocessing'
56
56
  complete -c kesha -n '__fish_seen_subcommand_from install' -l diarize -d 'Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)'
57
+ complete -c kesha -n '__fish_seen_subcommand_from install' -l engine-version -r -d 'Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)'
57
58
  complete -c kesha -n '__fish_seen_subcommand_from logs' -l help -d 'Show help'
58
59
  complete -c kesha -n '__fish_seen_subcommand_from logs' -s h -d 'Show help'
59
60
  complete -c kesha -n '__fish_seen_subcommand_from logs' -l json -d 'Output status as JSON'
@@ -75,7 +75,8 @@ _kesha() {
75
75
  '--plan[Show download, disk, and warm-up plan without changing local state]' \
76
76
  '--tts[Also install TTS models (English only; add codes for more, e.g. --tts en ru (preview sizes: kesha install --plan))]' \
77
77
  '--vad[Also install Silero VAD (~2.3MB) for long-audio preprocessing]' \
78
- '--diarize[Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)]'
78
+ '--diarize[Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)]' \
79
+ '--engine-version=[Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)]:engine version:'
79
80
  ;;
80
81
  logs)
81
82
  _arguments '--help[Show help]' \
@@ -60,27 +60,29 @@ The product promise is not "one model for every audio problem." The promise is a
60
60
 
61
61
  | Capability | macOS arm64 | macOS x64 | Linux x64 | Windows x64 | Nix path |
62
62
  |---|---|---|---|---|---|
63
- | STT | Supported, CoreML path | Not shipped | Supported, ONNX CPU path | Blocked at install (#216) | `aarch64-darwin`, `x86_64-linux` |
64
- | Audio language detection | Supported | Not shipped | Supported | Blocked at install (#216) | Supported where the flake builds |
65
- | VAD | Supported with `kesha install --vad` | Not shipped | Supported with `kesha install --vad` | Blocked at install (#216) | Supported where the engine path includes VAD assets |
66
- | TTS: English Kokoro | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path | Blocked at install (#216) | Supported except where noted in `docs/nix-install.md` |
67
- | TTS: Russian Vosk-TTS | Supported | Not shipped | Supported | Blocked at install (#216) | Supported except where noted in `docs/nix-install.md` |
63
+ | STT | Supported, CoreML path | Not shipped | Supported, ONNX CPU path | Supported, ONNX CPU path | `aarch64-darwin`, `x86_64-linux` |
64
+ | Audio language detection | Supported | Not shipped | Supported | Supported | Supported where the flake builds |
65
+ | VAD | Supported with `kesha install --vad` | Not shipped | Supported with `kesha install --vad` | Supported with `kesha install --vad` | Supported where the engine path includes VAD assets |
66
+ | TTS: English Kokoro | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path | Supported, ONNX path | Supported except where noted in `docs/nix-install.md` |
67
+ | TTS: Russian Vosk-TTS | Supported | Not shipped | Supported | Supported | Supported except where noted in `docs/nix-install.md` |
68
68
  | macOS system voices | Supported | Not shipped | Not applicable | Not applicable | Supported on `aarch64-darwin` |
69
69
  | Speaker diarization | Preview, darwin-arm64 only | Not supported | Not supported | Not supported | Not wired into the Nix build yet |
70
70
  | Raycast extension | Supported | Not shipped | Not applicable | Not applicable | Not applicable |
71
- | OpenClaw integration | Supported through CLI route | Not shipped | Supported through CLI route | Blocked at install (#216) | Use the installed `kesha` command from the chosen path |
72
- | Hermes Agent integration | Supported through command providers | Not shipped | Supported through command providers | Blocked at install (#216) | Use the installed `kesha` command from the chosen path |
71
+ | OpenClaw integration | Supported through CLI route | Not shipped | Supported through CLI route | Supported through CLI route | Use the installed `kesha` command from the chosen path |
72
+ | Hermes Agent integration | Supported through command providers | Not shipped | Supported through command providers | Supported through command providers | Use the installed `kesha` command from the chosen path |
73
73
 
74
74
  `macOS x64` means Intel Macs. Kesha does not currently publish a `darwin-x64` engine binary, so Intel Macs are intentionally marked as not shipped rather than implied to use the ONNX fallback.
75
75
 
76
- `Windows x64` shows "Blocked at install" because the CLI's install step
77
- refuses to run on Windows, not because no binary exists — `build-engine.yml`
78
- builds and publishes `kesha-engine-windows-x64.exe` with every release. The
79
- Vosk-TTS native dependency trips MSVC at link time, so the CLI blocks the
80
- install client-side rather than shipping a broken engine. See
81
- [issue #216](https://github.com/drakulavich/kesha-voice-kit/issues/216) for
82
- the underlying failure and the v1.4.x workaround; the toolkit is unusable on
83
- Windows until that lands.
76
+ `Windows x64` runs the same ONNX engine as Linux. The MSVC link failure behind
77
+ [issue #216](https://github.com/drakulavich/kesha-voice-kit/issues/216) was fixed by
78
+ vendoring the Vosk-TTS runtime under `rust/vendor/vosk-tts/`, and the CLI's install
79
+ refusal — which outlived that fix by about twenty minor versions — is gone. CI now runs
80
+ a cold `kesha install` on `windows-latest`, synthesises with both the Kokoro and Vosk
81
+ engines, and transcribes the result back, so the platform's row reflects a tested path
82
+ rather than a published binary nobody had run.
83
+
84
+ Microphone capture is the one Linux/Windows gap that is not about Apple frameworks:
85
+ `kesha record` is macOS-only, so pass an existing audio file instead.
84
86
 
85
87
  ## When to choose something else
86
88
 
package/man/kesha.1 CHANGED
@@ -141,6 +141,9 @@ Also install Silero VAD (~2.3MB) for long\-audio preprocessing
141
141
  .TP
142
142
  .B \-\-diarize
143
143
  Also install the Sortformer streaming\-diarization model (~245MB, darwin\-arm64 only — #199)
144
+ .TP
145
+ .B \-\-engine\-version
146
+ Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)
144
147
  .SS logs
145
148
  .TP
146
149
  .B \-\-json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.25.0",
3
+ "version": "1.27.0-alpha.1",
4
4
  "description": "Give your tools a voice — speech to text and back, 25 languages, up to ~19× faster than Whisper. On your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,8 @@
25
25
  "SKILL.md",
26
26
  "LICENSE",
27
27
  "NOTICES.md",
28
- "README.md"
28
+ "README.md",
29
+ "!src/__tests__"
29
30
  ],
30
31
  "openclaw": {
31
32
  "extensions": [
@@ -57,7 +58,8 @@
57
58
  "check:workflows": "bun .github/scripts/check-workflows.ts",
58
59
  "check:specs": "bunx @fission-ai/openspec@1.4.1 validate --specs --strict",
59
60
  "check:release-manifest": "bun .github/scripts/release-manifest.mjs --check",
60
- "check:versions": "bun .github/scripts/check-versions.ts"
61
+ "check:versions": "bun .github/scripts/check-versions.ts",
62
+ "check:engine-targets": "bun .github/scripts/check-engine-targets.ts"
61
63
  },
62
64
  "keywords": [
63
65
  "asr",
@@ -102,6 +104,7 @@
102
104
  },
103
105
  "overrides": {
104
106
  "hono": "^4.12.25",
105
- "fast-uri": "^3.1.3"
107
+ "fast-uri": "^3.1.5",
108
+ "ip-address": "^10.3.1"
106
109
  }
107
110
  }
package/src/cli/init.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { defineCommand } from "citty";
2
- import { createInterface } from "node:readline/promises";
3
- import { stdin as input, stdout as output } from "node:process";
4
- import { multiselect, isCancel, cancel } from "@clack/prompts";
2
+ import { confirm, multiselect, isCancel, cancel } from "@clack/prompts";
5
3
  import { renderInstallPlan } from "../install-plan";
6
4
  import { log } from "../log";
7
5
  import { getEngineCapabilities } from "../engine";
8
- import { performInstall, resolveBackendFlag, resolveNoCacheFlag, TTS_LANG_FALLBACK } from "./install";
6
+ import {
7
+ performInstall,
8
+ resolveBackendFlag,
9
+ resolveNoCacheFlag,
10
+ TTS_LANG_FALLBACK,
11
+ unavailableBackendError,
12
+ } from "./install";
9
13
  import type { SharedInstallArgs } from "./types";
10
14
 
11
15
  const TTS_LANG_LABELS: Record<string, string> = {
@@ -23,6 +27,18 @@ const TTS_LANG_LABELS: Record<string, string> = {
23
27
  /** Signature of the interactive TTS-language picker (injectable for tests). */
24
28
  export type TtsLangPrompt = (preselect: string[]) => Promise<string[]>;
25
29
 
30
+ /** Signature of the interactive yes/no prompt (injectable for tests). */
31
+ export type ConfirmPrompt = (message: string, initialValue: boolean) => Promise<boolean>;
32
+
33
+ /** Ctrl-C at any prompt ends init without downloading, rather than falling through with a default. */
34
+ function exitIfCancelled<T>(answer: T | symbol): T {
35
+ if (isCancel(answer)) {
36
+ cancel("Init cancelled.");
37
+ process.exit(0);
38
+ }
39
+ return answer as T;
40
+ }
41
+
26
42
  async function promptTtsLangs(preselect: string[]): Promise<string[]> {
27
43
  const caps = await getEngineCapabilities();
28
44
  const supported = caps?.tts?.languages.map((l) => l.code) ?? TTS_LANG_FALLBACK;
@@ -33,11 +49,12 @@ async function promptTtsLangs(preselect: string[]): Promise<string[]> {
33
49
  initialValues: preselect.filter((l) => supported.includes(l)),
34
50
  required: false,
35
51
  });
36
- if (isCancel(selected)) {
37
- cancel("Init cancelled.");
38
- process.exit(0);
39
- }
40
- return selected as string[];
52
+ return exitIfCancelled(selected);
53
+ }
54
+
55
+ /** #677: keep every prompt on clack — its close() unpipes stdin, deadlocking any readline sharing it. */
56
+ async function promptConfirm(message: string, initialValue: boolean): Promise<boolean> {
57
+ return exitIfCancelled(await confirm({ message, initialValue }));
41
58
  }
42
59
 
43
60
  export interface InitCommandArgs extends SharedInstallArgs {
@@ -52,10 +69,6 @@ export interface InitSelection {
52
69
  diarize: boolean;
53
70
  }
54
71
 
55
- interface PromptApi {
56
- question(prompt: string): Promise<string>;
57
- }
58
-
59
72
  export function canInstallDiarizeOnPlatform(
60
73
  platform = process.platform,
61
74
  arch = process.arch,
@@ -140,17 +153,17 @@ export function renderInitOverview(canDiarize = canInstallDiarizeOnPlatform()):
140
153
 
141
154
  export async function promptInitSelection(
142
155
  args: InitCommandArgs,
143
- prompt: PromptApi,
156
+ prompt: ConfirmPrompt,
144
157
  backend = resolveBackendFlag(args.coreml, args.onnx),
145
158
  canDiarize = canInstallDiarizeOnPlatform(),
146
159
  noCache = resolveNoCacheFlag(args),
147
160
  promptTts: TtsLangPrompt = promptTtsLangs,
148
161
  ): Promise<InitSelection> {
149
162
  const ttsLangs = await promptTts(args.tts ? ["en"] : []);
150
- const vad = await askYesNo(prompt, "Install VAD for long or silence-heavy audio?", args.vad);
163
+ const vad = await prompt("Install VAD for long or silence-heavy audio?", args.vad);
151
164
  let diarize = false;
152
165
  if (canDiarize) {
153
- diarize = await askYesNo(prompt, "Install speaker diarization for `--speakers`?", args.diarize);
166
+ diarize = await prompt("Install speaker diarization for `--speakers`?", args.diarize);
154
167
  } else if (args.diarize) {
155
168
  log.warn("--diarize is currently darwin-arm64 only; omitting it from the interactive install.");
156
169
  }
@@ -164,18 +177,14 @@ export async function promptInitSelection(
164
177
  };
165
178
  }
166
179
 
167
- async function askYesNo(prompt: PromptApi, message: string, defaultValue: boolean): Promise<boolean> {
168
- const suffix = defaultValue ? "Y/n" : "y/N";
169
- for (;;) {
170
- const answer = (await prompt.question(`${message} [${suffix}] `)).trim().toLowerCase();
171
- if (answer === "") return defaultValue;
172
- if (answer === "y" || answer === "yes") return true;
173
- if (answer === "n" || answer === "no") return false;
174
- log.warn("Please answer yes or no.");
180
+ /** Mirrors the guard in `performInstall`: a preview must not describe an install this platform rejects (#684). */
181
+ async function printPlan(selection: InitSelection): Promise<boolean> {
182
+ const backendError = unavailableBackendError(selection.backend);
183
+ if (backendError) {
184
+ log.error(backendError);
185
+ process.exitCode = 2;
186
+ return false;
175
187
  }
176
- }
177
-
178
- async function printPlan(selection: InitSelection): Promise<void> {
179
188
  log.info(
180
189
  await renderInstallPlan({
181
190
  noCache: selection.noCache,
@@ -185,6 +194,7 @@ async function printPlan(selection: InitSelection): Promise<void> {
185
194
  diarize: selection.diarize,
186
195
  }),
187
196
  );
197
+ return true;
188
198
  }
189
199
 
190
200
  async function runNonInteractive(selection: InitSelection): Promise<void> {
@@ -194,7 +204,7 @@ async function runNonInteractive(selection: InitSelection): Promise<void> {
194
204
  log.warn("--diarize is currently darwin-arm64 only; omitting it from non-interactive examples.");
195
205
  }
196
206
  log.info(renderInitOverview(canDiarize));
197
- await printPlan(printableSelection);
207
+ if (!(await printPlan(printableSelection))) return;
198
208
  log.info("Run one of these commands from an interactive terminal:");
199
209
  for (const command of initSuggestionCommands(printableSelection, canDiarize)) {
200
210
  log.info(` ${command.join(" ")}`);
@@ -264,13 +274,7 @@ export const initCommand = defineCommand({
264
274
  if (selection.diarize && !installSelection.diarize) {
265
275
  log.warn("--diarize is currently darwin-arm64 only; omitting it from the --yes install.");
266
276
  }
267
- await performInstall(
268
- installSelection.noCache,
269
- installSelection.backend,
270
- installSelection.ttsLangs,
271
- installSelection.vad,
272
- installSelection.diarize,
273
- );
277
+ await performInstall(installSelection);
274
278
  return;
275
279
  }
276
280
 
@@ -282,33 +286,30 @@ export const initCommand = defineCommand({
282
286
  }
283
287
 
284
288
  log.info(renderInitOverview());
285
- const rl = createInterface({ input, output });
286
- try {
287
- const prompted = await promptInitSelection(args, rl, backend, canInstallDiarizeOnPlatform(), noCache);
288
- log.info("");
289
- await printPlan(prompted);
290
- const confirmed = await askYesNo(rl, `Run \`${initInstallArgs(prompted).join(" ")}\` now?`, true);
291
- if (!confirmed) {
292
- log.info(`Skipped install. Run later: ${initInstallArgs(prompted).join(" ")}`);
293
- return;
294
- }
295
- await performInstall(
296
- prompted.noCache,
297
- prompted.backend,
298
- prompted.ttsLangs,
299
- prompted.vad,
300
- prompted.diarize,
301
- );
302
- // Next-steps hint (#523): leave the user with something runnable rather
303
- // than ending on the install log.
304
- log.info("");
305
- log.success("Kesha is ready. Try:");
306
- log.info(" kesha path/to/audio.ogg Transcribe a file.");
307
- if (prompted.ttsLangs.length > 0) {
308
- log.info(' kesha say "hello" Speak text (TTS).');
309
- }
310
- } finally {
311
- rl.close();
289
+ const prompted = await promptInitSelection(
290
+ args,
291
+ promptConfirm,
292
+ backend,
293
+ canInstallDiarizeOnPlatform(),
294
+ noCache,
295
+ );
296
+ log.info("");
297
+ if (!(await printPlan(prompted))) return;
298
+ const confirmed = await promptConfirm(
299
+ `Run \`${initInstallArgs(prompted).join(" ")}\` now?`,
300
+ true,
301
+ );
302
+ if (!confirmed) {
303
+ log.info(`Skipped install. Run later: ${initInstallArgs(prompted).join(" ")}`);
304
+ return;
305
+ }
306
+ await performInstall(prompted);
307
+ // #523: end on something runnable, not on the install log.
308
+ log.info("");
309
+ log.success("Kesha is ready. Try:");
310
+ log.info(" kesha path/to/audio.ogg Transcribe a file.");
311
+ if (prompted.ttsLangs.length > 0) {
312
+ log.info(' kesha say "hello" Speak text (TTS).');
312
313
  }
313
314
  },
314
315
  });
@@ -1,17 +1,22 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
- import { downloadEngine } from "../engine-install";
3
+ import { installEngine } from "../engine-install";
4
+ import { engineTarget } from "../engine-targets";
4
5
  import { getEngineBinPath, getEngineCapabilities } from "../engine";
5
6
  import { renderInstallPlan } from "../install-plan";
6
7
  import { maybeAskForStar } from "../star";
7
8
  import { log } from "../log";
8
9
  import { packageVersion } from "../package-info";
10
+ import { isSemver } from "../semver.mjs";
9
11
  import { createDiagnosticLogSession, type DiagnosticLogSession } from "../diagnostic-log";
10
12
  import type { SharedInstallArgs } from "./types";
11
13
 
12
14
  export interface InstallCommandArgs extends SharedInstallArgs {
13
15
  /** Positional args after `install` — candidate TTS language codes. */
14
16
  _?: string[];
17
+ /** `init` deliberately has no such flag: an override is an expert action, not a guided one. */
18
+ "engine-version"?: string;
19
+ engineVersion?: string;
15
20
  }
16
21
 
17
22
  /**
@@ -56,6 +61,23 @@ export function resolveTtsLangs(input: TtsArgInput, supported: string[] | undefi
56
61
  return langs;
57
62
  }
58
63
 
64
+ /**
65
+ * Validates `--engine-version` before any network call. Returns undefined when the flag is
66
+ * absent, so the Pinned Engine version stays the default; throws on anything the version
67
+ * drift gate would not accept either — one SemVer 2.0 definition across the repo.
68
+ */
69
+ export function resolveEngineVersionFlag(raw: unknown): string | undefined {
70
+ if (raw === undefined || raw === null || raw === false) return undefined;
71
+ const value = String(raw).trim();
72
+ if (isSemver(value)) return value;
73
+ const hint = /^v\d/.test(value)
74
+ ? ' Drop the leading "v" — that belongs to the release tag, not the version.'
75
+ : "";
76
+ throw new Error(
77
+ `--engine-version needs an exact SemVer 2.0 version like 1.24.8 or 1.24.8-alpha.1, got "${value}".${hint}`,
78
+ );
79
+ }
80
+
59
81
  export function resolveNoCacheFlag(
60
82
  args: Pick<InstallCommandArgs, "no-cache" | "noCache" | "no_cache">,
61
83
  rawArgs: string[] = [],
@@ -90,10 +112,11 @@ export function resolveBackendFlag(coreml: boolean, onnx: boolean): string | und
90
112
  return selection.backend;
91
113
  }
92
114
 
93
- function defaultBackendForPlatform(): string | undefined {
94
- if (process.platform === "darwin" && process.arch === "arm64") return "coreml";
95
- if (process.platform === "linux" && process.arch === "x64") return "onnx";
96
- return undefined;
115
+ export function defaultBackendForPlatform(
116
+ platform: string = process.platform,
117
+ arch: string = process.arch,
118
+ ): string | undefined {
119
+ return engineTarget(platform, arch)?.backend;
97
120
  }
98
121
 
99
122
  type InstallDiagnosticErrorKind = "validation_failed" | "install_failed";
@@ -118,16 +141,42 @@ function finishInstallDiagnostic(
118
141
  }
119
142
  }
120
143
 
121
- export async function performInstall(
122
- noCache: boolean,
123
- backend: string | undefined,
124
- ttsLangs: string[],
125
- vad = false,
126
- diarize = false,
127
- plan = false,
128
- ) {
144
+ /** Null when the requested backend is installable here. `KESHA_ENGINE_BIN` opts out — the user supplied their own engine. */
145
+ export function unavailableBackendError(backend: string | undefined): string | null {
146
+ const platformBackend = defaultBackendForPlatform();
147
+ if (!backend || process.env.KESHA_ENGINE_BIN || !platformBackend) return null;
148
+ if (backend === platformBackend) return null;
149
+ return (
150
+ `Requested backend "${backend}" is not available on this platform; ` +
151
+ `the release engine uses "${platformBackend}".`
152
+ );
153
+ }
154
+
155
+ export interface PerformInstallOptions {
156
+ noCache: boolean;
157
+ backend?: string;
158
+ ttsLangs: string[];
159
+ vad?: boolean;
160
+ diarize?: boolean;
161
+ plan?: boolean;
162
+ /** Engine release to install instead of the pin, for this invocation only. */
163
+ engineVersion?: string;
164
+ }
165
+
166
+ export async function performInstall(options: PerformInstallOptions) {
167
+ const { noCache, backend, ttsLangs, vad = false, diarize = false, plan = false, engineVersion } =
168
+ options;
169
+ // A plan for an unavailable backend previews what its own printed command rejects (#684).
170
+ const backendError = unavailableBackendError(backend);
129
171
  if (plan) {
130
- log.info(await renderInstallPlan({ noCache, backend, ttsLangs, vad, diarize }));
172
+ if (backendError) {
173
+ log.error(backendError);
174
+ process.exitCode = 2;
175
+ return;
176
+ }
177
+ log.info(
178
+ await renderInstallPlan({ noCache, backend, ttsLangs, vad, diarize, engineVersion }),
179
+ );
131
180
  return;
132
181
  }
133
182
 
@@ -143,6 +192,7 @@ export async function performInstall(
143
192
  tts: ttsLangs.length > 0,
144
193
  vad,
145
194
  diarize,
195
+ engineVersionOverride: engineVersion !== undefined,
146
196
  });
147
197
 
148
198
  if (diarize && !(process.platform === "darwin" && process.arch === "arm64")) {
@@ -152,15 +202,11 @@ export async function performInstall(
152
202
  "(see https://github.com/drakulavich/kesha-voice-kit/issues/199).",
153
203
  );
154
204
  }
155
- const platformBackend = defaultBackendForPlatform();
156
- if (backend && !process.env.KESHA_ENGINE_BIN && platformBackend && backend !== platformBackend) {
205
+ if (backendError) {
157
206
  errorKind = "validation_failed";
158
- throw new Error(
159
- `Requested backend "${backend}" is not available on this platform; ` +
160
- `the release engine uses "${platformBackend}".`,
161
- );
207
+ throw new Error(backendError);
162
208
  }
163
- await downloadEngine(noCache, backend, { ttsLangs, vad, diarize });
209
+ await installEngine({ noCache, backend, ttsLangs, vad, diarize, version: engineVersion });
164
210
  await maybeAskForStar(getEngineBinPath(), packageVersion, log);
165
211
  finishInstallDiagnostic(diagnosticLog, startedAt, "success");
166
212
  } catch (err: unknown) {
@@ -212,8 +258,20 @@ export const installCommand = defineCommand({
212
258
  description: "Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)",
213
259
  default: false,
214
260
  },
261
+ "engine-version": {
262
+ type: "string",
263
+ description:
264
+ "Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)",
265
+ },
215
266
  },
216
267
  async run({ args, rawArgs }: { args: InstallCommandArgs; rawArgs: string[] }) {
268
+ let engineVersion: string | undefined;
269
+ try {
270
+ engineVersion = resolveEngineVersionFlag(args["engine-version"] ?? args.engineVersion);
271
+ } catch (err) {
272
+ log.error(errorMessage(err));
273
+ process.exit(2);
274
+ }
217
275
  const backend = resolveBackendFlag(args.coreml, args.onnx);
218
276
  const positionals = (args._ ?? []).map(String);
219
277
  const caps = await getEngineCapabilities();
@@ -225,13 +283,14 @@ export const installCommand = defineCommand({
225
283
  log.error(errorMessage(err));
226
284
  process.exit(1);
227
285
  }
228
- await performInstall(
229
- resolveNoCacheFlag(args, rawArgs),
286
+ await performInstall({
287
+ noCache: resolveNoCacheFlag(args, rawArgs),
230
288
  backend,
231
289
  ttsLangs,
232
- args.vad,
233
- args.diarize,
234
- args.plan,
235
- );
290
+ vad: args.vad,
291
+ diarize: args.diarize,
292
+ plan: args.plan,
293
+ engineVersion,
294
+ });
236
295
  },
237
296
  });
package/src/cli/status.ts CHANGED
@@ -26,8 +26,7 @@ export const statusCommand = defineCommand({
26
26
  async run({ args }: { args: StatusCommandArgs }) {
27
27
  const report = await collectStatus({ disk: args.disk });
28
28
  if (args.json) {
29
- // `log.*` writes to stdout, so the payload must be the only thing printed —
30
- // the setup hint ships inside it instead of on stderr (#647).
29
+ // `log.*` writes to stdout, so the payload must be the only thing printed (#647).
31
30
  console.log(JSON.stringify(report, null, 2));
32
31
  return;
33
32
  }