@drakulavich/kesha-voice-kit 1.24.6 → 1.24.7

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
@@ -16,7 +16,7 @@
16
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
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
- - **Small Rust engine** — single ~20MB binary, no ffmpeg, no Python, no native Node addons
19
+ - **Small Rust engine** — single ~60MB binary, no ffmpeg, no Python, no native Node addons
20
20
 
21
21
  <p align="center">
22
22
  <img src="https://github.com/drakulavich/kesha-voice-kit/raw/main/demo.gif" alt="kesha demo — English + Russian transcription with automatic language detection" width="800">
@@ -24,16 +24,21 @@
24
24
 
25
25
  ## Quick Start
26
26
 
27
- Runtime: **[Bun](https://bun.sh)** >= 1.3.0 · Platforms: macOS arm64, Linux x64, Windows x64.
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)).
28
28
 
29
29
  ```bash
30
30
  # 1. Install Bun (skip if you have it) — Linux & macOS:
31
31
  curl -fsSL https://bun.sh/install | bash # or: brew install oven-sh/bun/bun
32
32
  # Windows: powershell -c "irm bun.sh/install.ps1 | iex"
33
+ # if `bun --version` fails, reload PATH: exec $SHELL -l
33
34
 
34
35
  # 2. Install Kesha:
35
36
  bun add -g @drakulavich/kesha-voice-kit
36
- kesha install # downloads engine + models (explicit — never automatic)
37
+ kesha --version # confirms `kesha` resolved on PATH
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.
40
+ # No progress bar during the model step; can take several minutes.
41
+ # Prefer a guided wizard? `kesha init` walks through the same choices interactively.
37
42
 
38
43
  # 3. Transcribe:
39
44
  kesha audio.ogg # transcript to stdout
@@ -51,6 +56,7 @@ kesha --format json audio.ogg # full JSON with lang fields
51
56
  kesha --json --timestamps audio.ogg # JSON with timestamped segments
52
57
  kesha --toon audio.ogg # compact LLM-friendly TOON
53
58
  kesha status # show installed backend info
59
+ kesha status --disk # + recursive cache disk usage
54
60
  ```
55
61
 
56
62
  Multiple files get `head`-style headers; stdout is the transcript, stderr is errors — pipe-friendly:
@@ -64,6 +70,7 @@ $ kesha freedom.ogg tahiti.ogg
64
70
  Таити, Таити! Не были мы ни в какой Таити! Нас и тут неплохо кормят.
65
71
  ```
66
72
 
73
+ - **Record from the mic (macOS):** `kesha record --out hello.wav` writes microphone audio to a WAV file (`kesha hello.wav` transcribes it). macOS prompts for microphone access on first use — grant it under System Settings → Privacy & Security → Microphone if it was denied. On Linux/Windows or headless boxes, pass any existing audio file straight to `kesha` instead.
67
74
  - **Long / silence-heavy audio:** install VAD (`kesha install --vad`); Kesha auto-uses it past 120 s. Without VAD, long audio falls back to fixed ASR chunks. See [docs/vad.md](docs/vad.md).
68
75
  - **Speaker diarization** (darwin-arm64): `kesha install --diarize`, then `kesha --json --vad --speakers meeting.m4a` stamps each segment with a `speaker` id. Linux/Windows return a clear "darwin-arm64 only" error ([#199](https://github.com/drakulavich/kesha-voice-kit/issues/199)).
69
76
 
@@ -72,7 +79,8 @@ $ kesha freedom.ogg tahiti.ogg
72
79
  Kesha speaks back in [9 languages](docs/languages.md#text-to-speech), auto-picking the voice from the text's language. Override with `--lang <code>` or `--voice <id>`.
73
80
 
74
81
  ```bash
75
- kesha install --tts # opt-in models (~990MB)
82
+ kesha install --tts # English voices; sizes differ per platform — preview: kesha install --plan
83
+ kesha install --tts en ru # + Russian (+~890 MB, Vosk)
76
84
  kesha say "Hello, world" > hello.wav
77
85
  kesha say "Привет, мир" > privet.wav # auto-routes by language
78
86
  kesha say --voice ru-vosk-m02 "Голос в текст." > ru.wav
@@ -117,7 +125,7 @@ All of these install the Bun CLI wrapper; engine + models still download explici
117
125
  - **MCP server** — `kesha mcp` exposes transcribe/synthesize/list tools to any MCP client (Claude, Cursor, Codex, Gemini). Setup: [docs/mcp.md](docs/mcp.md).
118
126
  - **OpenClaw** — give your LLM agent ears. Install & config: [docs/openclaw.md](docs/openclaw.md).
119
127
  - **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
120
- - **Raycast** (macOS) — transcribe selected audio & speak the clipboard from the launcher. Source + install: [`raycast/`](raycast/).
128
+ - **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/).
121
129
  - **Programmatic API** — `@drakulavich/kesha-voice-kit/core` for use inside a Bun program. See [docs/api.md](docs/api.md).
122
130
 
123
131
  ## More
@@ -42,7 +42,7 @@ complete -c kesha -n '__fish_seen_subcommand_from init' -l onnx -d 'Preselect ON
42
42
  complete -c kesha -n '__fish_seen_subcommand_from init' -l no-cache -d 'Re-download even if cached'
43
43
  complete -c kesha -n '__fish_seen_subcommand_from init' -l plan -d 'Show the selected install plan without downloading'
44
44
  complete -c kesha -n '__fish_seen_subcommand_from init' -l yes -d 'Accept defaults and run without prompts'
45
- complete -c kesha -n '__fish_seen_subcommand_from init' -l tts -d 'Preselect TTS models (Kokoro EN + Vosk-TTS RU, ~990MB)'
45
+ complete -c kesha -n '__fish_seen_subcommand_from init' -l tts -d 'Preselect TTS models (English; more languages via kesha install --tts <langs>)'
46
46
  complete -c kesha -n '__fish_seen_subcommand_from init' -l vad -d 'Preselect Silero VAD (~2.3MB) for long-audio preprocessing'
47
47
  complete -c kesha -n '__fish_seen_subcommand_from init' -l diarize -d 'Preselect Sortformer speaker diarization (~245MB, darwin-arm64 only)'
48
48
  complete -c kesha -n '__fish_seen_subcommand_from install' -l help -d 'Show help'
@@ -51,7 +51,7 @@ complete -c kesha -n '__fish_seen_subcommand_from install' -l coreml -d 'Force C
51
51
  complete -c kesha -n '__fish_seen_subcommand_from install' -l onnx -d 'Force ONNX backend'
52
52
  complete -c kesha -n '__fish_seen_subcommand_from install' -l no-cache -d 'Re-download even if cached'
53
53
  complete -c kesha -n '__fish_seen_subcommand_from install' -l plan -d 'Show download, disk, and warm-up plan without changing local state'
54
- complete -c kesha -n '__fish_seen_subcommand_from install' -l tts -d 'Also install TTS models (Kokoro EN + Vosk-TTS RU, ~990MB)'
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
57
  complete -c kesha -n '__fish_seen_subcommand_from logs' -l help -d 'Show help'
@@ -62,7 +62,7 @@ _kesha() {
62
62
  '--no-cache[Re-download even if cached]' \
63
63
  '--plan[Show the selected install plan without downloading]' \
64
64
  '--yes[Accept defaults and run without prompts]' \
65
- '--tts[Preselect TTS models (Kokoro EN + Vosk-TTS RU, ~990MB)]' \
65
+ '--tts[Preselect TTS models (English; more languages via kesha install --tts <langs>)]' \
66
66
  '--vad[Preselect Silero VAD (~2.3MB) for long-audio preprocessing]' \
67
67
  '--diarize[Preselect Sortformer speaker diarization (~245MB, darwin-arm64 only)]'
68
68
  ;;
@@ -73,7 +73,7 @@ _kesha() {
73
73
  '--onnx[Force ONNX backend]' \
74
74
  '--no-cache[Re-download even if cached]' \
75
75
  '--plan[Show download, disk, and warm-up plan without changing local state]' \
76
- '--tts[Also install TTS models (Kokoro EN + Vosk-TTS RU, ~990MB)]' \
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
78
  '--diarize[Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)]'
79
79
  ;;
@@ -60,19 +60,28 @@ 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 | 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` |
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` |
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 | 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 |
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 |
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.
84
+
76
85
  ## When to choose something else
77
86
 
78
87
  Use a hosted transcription provider when you need managed uptime, centralized audit logs, team permissions, or no local model cache. Use a streaming ASR stack when partial transcripts during a live call matter more than local-first batch reliability. Use a domain-specific ASR model when medical, legal, or noisy industrial audio quality is the primary requirement.
package/man/kesha.1 CHANGED
@@ -112,7 +112,7 @@ Show the selected install plan without downloading
112
112
  Accept defaults and run without prompts
113
113
  .TP
114
114
  .B \-\-tts
115
- Preselect TTS models (Kokoro EN + Vosk\-TTS RU, ~990MB)
115
+ Preselect TTS models (English; more languages via kesha install \-\-tts <langs>)
116
116
  .TP
117
117
  .B \-\-vad
118
118
  Preselect Silero VAD (~2.3MB) for long\-audio preprocessing
@@ -134,7 +134,7 @@ Re\-download even if cached
134
134
  Show download, disk, and warm\-up plan without changing local state
135
135
  .TP
136
136
  .B \-\-tts
137
- Also install TTS models (Kokoro EN + Vosk\-TTS RU, ~990MB)
137
+ Also install TTS models (English only; add codes for more, e.g. \-\-tts en ru (preview sizes: kesha install \-\-plan))
138
138
  .TP
139
139
  .B \-\-vad
140
140
  Also install Silero VAD (~2.3MB) for long\-audio preprocessing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.24.6",
3
+ "version": "1.24.7",
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": {
@@ -38,14 +38,14 @@
38
38
  }
39
39
  },
40
40
  "keshaEngine": {
41
- "version": "1.24.6"
41
+ "version": "1.24.7"
42
42
  },
43
43
  "scripts": {
44
- "test": "bun test",
45
- "test:watch": "bun test --watch",
44
+ "test": "bun test --timeout 30000",
45
+ "test:watch": "bun test --timeout 30000 --watch",
46
46
  "test:unit": "bun test tests/unit/",
47
- "test:cli-fast": "bun test tests/unit/ tests/integration/cli-contracts.test.ts tests/integration/e2e-cli.test.ts tests/integration/say.test.ts",
48
- "test:integration": "bun test tests/integration/",
47
+ "test:cli-fast": "bun test --timeout 30000 tests/unit/ tests/integration/cli-contracts.test.ts tests/integration/e2e-cli.test.ts tests/integration/say.test.ts",
48
+ "test:integration": "bun test --timeout 30000 tests/integration/",
49
49
  "coverage:ts": "bun run test:cli-fast --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage/ts",
50
50
  "coverage:check:ts": "bun .github/scripts/check-coverage.ts --preset=ts --lcov=coverage/ts/lcov.info --summary=coverage/ts/summary.md",
51
51
  "coverage:rust": "mkdir -p coverage/rust && cd rust && cargo llvm-cov nextest --features tts --lcov --output-path ../coverage/rust/lcov.info",
@@ -54,6 +54,7 @@
54
54
  "lint": "bunx tsc --noEmit",
55
55
  "check": "bun run lint && bun run check:versions && bun run test:cli-fast",
56
56
  "check:workflows": "bun .github/scripts/check-workflows.ts",
57
+ "check:specs": "bunx @fission-ai/openspec@1.4.1 validate --specs --strict",
57
58
  "check:release-manifest": "bun .github/scripts/release-manifest.mjs --check",
58
59
  "check:versions": "bun .github/scripts/check-versions.ts"
59
60
  },
@@ -0,0 +1,18 @@
1
+ import { log } from "../log";
2
+
3
+ export type ActionMessage = { level: "success" | "info" | "warn"; text: string };
4
+
5
+ export type ActionResult =
6
+ | { ok: true; messages: ActionMessage[]; stdout?: string }
7
+ | { ok: false; error: string; hint?: string };
8
+
9
+ /** Renders an action's messages, writing `stdout` verbatim; a rejected action exits 2. */
10
+ export function emitActionResult(result: ActionResult): void {
11
+ if (!result.ok) {
12
+ log.error(result.error);
13
+ if (result.hint) log.warn(result.hint);
14
+ process.exit(2);
15
+ }
16
+ for (const message of result.messages) log[message.level](message.text);
17
+ if (result.stdout !== undefined) process.stdout.write(result.stdout);
18
+ }
@@ -0,0 +1,86 @@
1
+ import { createDiagnosticLogSession } from "../diagnostic-log";
2
+ import type {
3
+ DiagnosticLogFields,
4
+ DiagnosticLogSession,
5
+ DiagnosticSessionStatus,
6
+ } from "../diagnostic-log";
7
+ import { errorMessage } from "../error-utils";
8
+ import { log } from "../log";
9
+ import { createStatsRecorder } from "../stats";
10
+ import type { StatsCommandName, StatsRecorder } from "../stats";
11
+
12
+ export interface CommandSession {
13
+ readonly stats: StatsRecorder;
14
+ readonly diagnosticLog: DiagnosticLogSession;
15
+ }
16
+
17
+ export interface CommandOutcome {
18
+ status: DiagnosticSessionStatus;
19
+ itemCount: number;
20
+ /** Merged into the `command.finish` event alongside `command` and `status`. */
21
+ finishFields?: DiagnosticLogFields;
22
+ /** Exit code the caller should terminate with; omitted means "don't exit". */
23
+ exitCode?: number;
24
+ }
25
+
26
+ export interface CommandSessionFactories {
27
+ createStats: (command: StatsCommandName) => StatsRecorder;
28
+ createDiagnosticLog: () => DiagnosticLogSession;
29
+ }
30
+
31
+ const defaultFactories: CommandSessionFactories = {
32
+ createStats: createStatsRecorder,
33
+ createDiagnosticLog: createDiagnosticLogSession,
34
+ };
35
+
36
+ /** Runs one CLI command's body between `command.start` / `command.finish`, closing both recorders exactly once; `body` reports failure by returning a {@link CommandOutcome}, never by exiting. */
37
+ export async function runCommandSession(
38
+ command: StatsCommandName,
39
+ startFields: DiagnosticLogFields,
40
+ body: (session: CommandSession) => Promise<CommandOutcome>,
41
+ factories: CommandSessionFactories = defaultFactories,
42
+ ): Promise<CommandOutcome> {
43
+ const session: CommandSession = {
44
+ stats: factories.createStats(command),
45
+ diagnosticLog: factories.createDiagnosticLog(),
46
+ };
47
+ session.diagnosticLog.event("command.start", { command, ...startFields });
48
+
49
+ let outcome: CommandOutcome;
50
+ try {
51
+ outcome = await body(session);
52
+ } catch (err) {
53
+ closeSessionQuietly(session, command, { status: "failed", itemCount: 0 });
54
+ throw err;
55
+ }
56
+
57
+ // A flush error must not displace a failure the command already has to report (Greptile P1/P2 on #607).
58
+ if (outcome.status === "failed") {
59
+ closeSessionQuietly(session, command, outcome);
60
+ } else {
61
+ closeSession(session, command, outcome);
62
+ }
63
+ return outcome;
64
+ }
65
+
66
+ function closeSessionQuietly(
67
+ session: CommandSession,
68
+ command: StatsCommandName,
69
+ outcome: CommandOutcome,
70
+ ): void {
71
+ try {
72
+ closeSession(session, command, outcome);
73
+ } catch (err) {
74
+ log.debug(`command session cleanup failed: ${errorMessage(err)}`);
75
+ }
76
+ }
77
+
78
+ function closeSession(session: CommandSession, command: StatsCommandName, outcome: CommandOutcome): void {
79
+ session.diagnosticLog.event("command.finish", {
80
+ command,
81
+ status: outcome.status,
82
+ ...outcome.finishFields,
83
+ });
84
+ session.stats.finish(outcome.status, outcome.itemCount);
85
+ session.diagnosticLog.finish(outcome.status);
86
+ }
@@ -0,0 +1,108 @@
1
+ import { log, setColorEnabled } from "../log";
2
+
3
+ /** The global flags resolved before citty, so every command sees the same decision. */
4
+ export interface CliContext {
5
+ quiet: boolean;
6
+ disableColor: boolean;
7
+ }
8
+
9
+ export interface ResolvedCliContext extends CliContext {
10
+ /** `rawArgs` with the global flag tokens removed, ready to hand to citty. */
11
+ rawArgs: string[];
12
+ }
13
+
14
+ // Falsey grammar shared by --no-color, --quiet, and CI. Mirrors KESHA_DEBUG so
15
+ // `CI=false`/`CI=0` and `--no-color=false` opt back in.
16
+ const FALSEY_VALUES = new Set(["", "0", "false", "no", "off"]);
17
+ function isFalsey(v: string): boolean {
18
+ return FALSEY_VALUES.has(v.trim().toLowerCase());
19
+ }
20
+
21
+ /** True when NO_COLOR came from the user's environment, so re-enabling colors must not clear it. */
22
+ export const USER_FORCED_NO_COLOR =
23
+ process.env.NO_COLOR !== undefined && !isFalsey(process.env.NO_COLOR);
24
+
25
+ /**
26
+ * Strip a boolean global flag out of rawArgs so citty never sees it. Matches
27
+ * any of `names` bare (sets the flag) and the `--name=<value>` form for the
28
+ * long names (`<falsey>` explicitly turns the flag back off).
29
+ */
30
+ function stripBooleanFlag(
31
+ rawArgs: string[],
32
+ names: string[],
33
+ ): { value: boolean; rawArgs: string[] } {
34
+ const valuedPrefixes = names.filter((n) => n.startsWith("--")).map((n) => `${n}=`);
35
+ let value = false;
36
+ const cleaned: string[] = [];
37
+ for (const arg of rawArgs) {
38
+ if (names.includes(arg)) {
39
+ value = true;
40
+ } else if (valuedPrefixes.some((prefix) => arg.startsWith(prefix))) {
41
+ value = !isFalsey(arg.slice(arg.indexOf("=") + 1));
42
+ } else {
43
+ cleaned.push(arg);
44
+ }
45
+ }
46
+ return { value, rawArgs: cleaned };
47
+ }
48
+
49
+ /**
50
+ * Decide whether ANSI colors should be disabled (#531) and return rawArgs with
51
+ * any `--no-color[=value]` token stripped so citty never sees it.
52
+ *
53
+ * Colors are disabled when `--no-color` (bare) or `--no-color=<truthy>` is
54
+ * passed, or when the environment looks like CI (`CI` set to a non-falsey value
55
+ * — GitHub Actions, GitLab, CircleCI, … export `CI=true`). `--no-color=false`
56
+ * and `CI=false`/`CI=0` explicitly opt back in.
57
+ */
58
+ export function resolveColorMode(
59
+ rawArgs: string[],
60
+ env: { CI?: string } = process.env as { CI?: string },
61
+ ): { disableColor: boolean; rawArgs: string[] } {
62
+ const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--no-color"]);
63
+ const ci = env.CI !== undefined && !isFalsey(env.CI);
64
+ return { disableColor: value || ci, rawArgs: cleaned };
65
+ }
66
+
67
+ /**
68
+ * Detect `--quiet`/`-q` (and `--quiet=<value>`) and return rawArgs with the
69
+ * token stripped (#526). Resolved before citty — like `--no-color` — so quiet
70
+ * is global: it works for every command, not just the transcribe path, and a
71
+ * subcommand that doesn't declare it never sees the flag.
72
+ */
73
+ export function resolveQuietMode(rawArgs: string[]): { quiet: boolean; rawArgs: string[] } {
74
+ const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--quiet", "-q"]);
75
+ return { quiet: value, rawArgs: cleaned };
76
+ }
77
+
78
+ /** Resolves every pre-citty global flag into one value, with the tokens stripped from rawArgs. */
79
+ export function resolveCliContext(
80
+ rawArgs: string[],
81
+ env: { CI?: string } = process.env as { CI?: string },
82
+ ): ResolvedCliContext {
83
+ const color = resolveColorMode(rawArgs, env);
84
+ const quiet = resolveQuietMode(color.rawArgs);
85
+ return { quiet: quiet.quiet, disableColor: color.disableColor, rawArgs: quiet.rawArgs };
86
+ }
87
+
88
+ /**
89
+ * Sync NO_COLOR with the resolved color decision so engine subprocesses inherit
90
+ * the right value. Separated from setColorEnabled so it can be tested without
91
+ * picocolors side-effects.
92
+ *
93
+ * Never clears a NO_COLOR the user exported before this process started.
94
+ */
95
+ export function applyColorEnv(disableColor: boolean): void {
96
+ if (disableColor) {
97
+ process.env.NO_COLOR = "1";
98
+ } else if (!USER_FORCED_NO_COLOR) {
99
+ delete process.env.NO_COLOR;
100
+ }
101
+ }
102
+
103
+ /** Applies the context to the process-wide output sinks, writing every value so an earlier in-process run (unit tests, `kesha mcp`) can't leak its settings into a later one. */
104
+ export function applyCliContext(context: CliContext): void {
105
+ setColorEnabled(!context.disableColor);
106
+ applyColorEnv(context.disableColor);
107
+ log.quietEnabled = context.quiet;
108
+ }
@@ -1,7 +1,8 @@
1
1
  import { runMain, type CommandDef } from "citty";
2
2
  import { existsSync } from "fs";
3
- import { log, setColorEnabled } from "../log";
3
+ import { log } from "../log";
4
4
  import { suggestCommand } from "../suggest-command";
5
+ import { applyCliContext, resolveCliContext } from "./context";
5
6
 
6
7
  // Lazy loaders so a cold CLI spawn transpiles only the invoked command, not the whole graph (#568).
7
8
  // `CommandDef<any>`: citty's generic is invariant in the arg shape and each command has its own schema.
@@ -25,86 +26,6 @@ function isPathLike(arg: string): boolean {
25
26
  return arg.includes(".") || arg.includes("/") || existsSync(arg);
26
27
  }
27
28
 
28
- // Falsey grammar shared by --no-color and CI. Mirrors KESHA_DEBUG so
29
- // `CI=false`/`CI=0` and `--no-color=false` opt back in to colors.
30
- const FALSEY_VALUES = new Set(["", "0", "false", "no", "off"]);
31
- function isFalsey(v: string): boolean {
32
- return FALSEY_VALUES.has(v.trim().toLowerCase());
33
- }
34
-
35
- // Captured at import so re-enabling colors never clobbers a user-exported NO_COLOR.
36
- const USER_FORCED_NO_COLOR =
37
- process.env.NO_COLOR !== undefined && !isFalsey(process.env.NO_COLOR);
38
-
39
- /**
40
- * Strip a boolean global flag out of rawArgs so citty never sees it. Matches
41
- * any of `names` bare (sets the flag) and the `--name=<value>` form for the
42
- * long names (`<falsey>` explicitly turns the flag back off).
43
- */
44
- function stripBooleanFlag(
45
- rawArgs: string[],
46
- names: string[],
47
- ): { value: boolean; rawArgs: string[] } {
48
- const valuedPrefixes = names.filter((n) => n.startsWith("--")).map((n) => `${n}=`);
49
- let value = false;
50
- const cleaned: string[] = [];
51
- for (const arg of rawArgs) {
52
- if (names.includes(arg)) {
53
- value = true;
54
- } else if (valuedPrefixes.some((prefix) => arg.startsWith(prefix))) {
55
- value = !isFalsey(arg.slice(arg.indexOf("=") + 1));
56
- } else {
57
- cleaned.push(arg);
58
- }
59
- }
60
- return { value, rawArgs: cleaned };
61
- }
62
-
63
- /**
64
- * Decide whether ANSI colors should be disabled (#531) and return rawArgs with
65
- * any `--no-color[=value]` token stripped so citty never sees it.
66
- *
67
- * Colors are disabled when `--no-color` (bare) or `--no-color=<truthy>` is
68
- * passed, or when the environment looks like CI (`CI` set to a non-falsey value
69
- * — GitHub Actions, GitLab, CircleCI, … export `CI=true`). `--no-color=false`
70
- * and `CI=false`/`CI=0` explicitly opt back in.
71
- */
72
- export function resolveColorMode(
73
- rawArgs: string[],
74
- env: { CI?: string } = process.env as { CI?: string },
75
- ): { disableColor: boolean; rawArgs: string[] } {
76
- const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--no-color"]);
77
- const ci = env.CI !== undefined && !isFalsey(env.CI);
78
- return { disableColor: value || ci, rawArgs: cleaned };
79
- }
80
-
81
- /**
82
- * Detect `--quiet`/`-q` (and `--quiet=<value>`) and return rawArgs with the
83
- * token stripped (#526). Resolved before citty — like `--no-color` — so quiet
84
- * is global: it works for every command, not just the transcribe path, and a
85
- * subcommand that doesn't declare it never sees the flag.
86
- */
87
- export function resolveQuietMode(rawArgs: string[]): { quiet: boolean; rawArgs: string[] } {
88
- const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--quiet", "-q"]);
89
- return { quiet: value, rawArgs: cleaned };
90
- }
91
-
92
- /**
93
- * Sync NO_COLOR with the resolved color decision so engine subprocesses inherit
94
- * the right value. Separated from setColorEnabled so it can be tested without
95
- * picocolors side-effects.
96
- *
97
- * Never clears a NO_COLOR the user exported before this process started
98
- * (USER_FORCED_NO_COLOR guard).
99
- */
100
- export function applyColorEnv(disableColor: boolean): void {
101
- if (disableColor) {
102
- process.env.NO_COLOR = "1";
103
- } else if (!USER_FORCED_NO_COLOR) {
104
- delete process.env.NO_COLOR;
105
- }
106
- }
107
-
108
29
  /**
109
30
  * Classify the first positional arg for routing.
110
31
  *
@@ -123,18 +44,35 @@ export function classifyFirstArg(
123
44
  return "unknown";
124
45
  }
125
46
 
126
- export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
127
- // Global flags resolved before citty so they apply to every command and never reach a subcommand's arg schema.
128
- const color = resolveColorMode(rawArgs);
129
- // Reset on every invocation so an earlier --no-color/CI call doesn't leave colors off for later in-process calls (unit tests, `kesha mcp`).
130
- setColorEnabled(!color.disableColor);
131
- applyColorEnv(color.disableColor);
47
+ export interface UnknownCommandMessages {
48
+ errorLine: string;
49
+ warnLines: string[];
50
+ }
51
+
52
+ /**
53
+ * Pure message builder for the unknown-token path (testable without
54
+ * `process.exit`). `transcribe` isn't a real subcommand — bare `kesha
55
+ * <audio-file>` is the invocation — so a near-miss of that word gets its own
56
+ * extra hint alongside the generic "pass a path" one.
57
+ */
58
+ export function unknownCommandMessages(token: string, subcommandKeys: string[]): UnknownCommandMessages {
59
+ const suggestion = suggestCommand(token, subcommandKeys);
60
+ const warnLines: string[] = [];
61
+ if (suggestion && suggestion !== token) {
62
+ warnLines.push(`(Did you mean ${suggestion}?)`);
63
+ }
64
+ warnLines.push(`If this is an audio file, pass a path like './${token}'.`);
65
+ if (suggestCommand(token, ["transcribe"]) === "transcribe") {
66
+ warnLines.push("To transcribe, pass the audio path directly: kesha ./recording.ogg");
67
+ }
68
+ return { errorLine: `unknown command '${token}'`, warnLines };
69
+ }
132
70
 
133
- const quiet = resolveQuietMode(color.rawArgs);
134
- log.quietEnabled = quiet.quiet;
71
+ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
72
+ const context = resolveCliContext(argv);
73
+ applyCliContext(context);
135
74
 
136
- rawArgs = quiet.rawArgs;
137
- const [firstArg, ...restArgs] = rawArgs;
75
+ const [firstArg, ...restArgs] = context.rawArgs;
138
76
  const subcommandKeys = Object.keys(SUBCOMMANDS);
139
77
 
140
78
  switch (classifyFirstArg(firstArg, subcommandKeys)) {
@@ -144,17 +82,16 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
144
82
 
145
83
  case "unknown": {
146
84
  // Extensionless existing files are valid transcription inputs; bare non-path tokens are likely command typos.
147
- const suggestion = suggestCommand(firstArg!, subcommandKeys);
148
- log.error(`unknown command '${firstArg}'`);
149
- if (suggestion && suggestion !== firstArg) {
150
- log.warn(`(Did you mean ${suggestion}?)`);
151
- }
152
- log.warn(`If this is an audio file, pass a path like './${firstArg}'.`);
85
+ const { errorLine, warnLines } = unknownCommandMessages(firstArg!, subcommandKeys);
86
+ log.error(errorLine);
87
+ for (const line of warnLines) log.warn(line);
153
88
  process.exit(1);
154
89
  break;
155
90
  }
156
91
 
157
- default:
158
- await runMain((await import("./main")).mainCommand, { rawArgs });
92
+ default: {
93
+ const { createMainCommand } = await import("./main");
94
+ await runMain(createMainCommand(context), { rawArgs: context.rawArgs });
95
+ }
159
96
  }
160
97
  }
package/src/cli/init.ts CHANGED
@@ -127,7 +127,7 @@ export function renderInitOverview(canDiarize = canInstallDiarizeOnPlatform()):
127
127
  "Kesha is a local voice toolkit. The base install downloads the engine, speech-to-text models, and language detection models.",
128
128
  "",
129
129
  "Optional features:",
130
- " - Text-to-speech: enables `kesha say` with Kokoro English and Vosk-TTS Russian voices (~990MB).",
130
+ " - Text-to-speech: enables `kesha say` with Kokoro English voices (more languages via `kesha install --tts <langs>`).",
131
131
  " - VAD: skips silence in long audio and improves meeting, lecture, and podcast transcripts (~2.3MB).",
132
132
  canDiarize
133
133
  ? " - Speaker diarization: labels speakers in JSON/TOON transcript segments (~245MB, darwin-arm64)."
@@ -234,7 +234,7 @@ export const initCommand = defineCommand({
234
234
  },
235
235
  tts: {
236
236
  type: "boolean",
237
- description: "Preselect TTS models (Kokoro EN + Vosk-TTS RU, ~990MB)",
237
+ description: "Preselect TTS models (English; more languages via kesha install --tts <langs>)",
238
238
  default: false,
239
239
  },
240
240
  vad: {
@@ -68,14 +68,26 @@ export function resolveNoCacheFlag(
68
68
  );
69
69
  }
70
70
 
71
- export function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined {
71
+ export type BackendSelection =
72
+ | { ok: true; backend: string | undefined }
73
+ | { ok: false; error: string };
74
+
75
+ export function selectBackend(coreml: boolean, onnx: boolean): BackendSelection {
72
76
  if (coreml && onnx) {
73
- log.error('Choose only one backend: "--coreml" or "--onnx".');
77
+ return { ok: false, error: 'Choose only one backend: "--coreml" or "--onnx".' };
78
+ }
79
+ if (coreml) return { ok: true, backend: "coreml" };
80
+ if (onnx) return { ok: true, backend: "onnx" };
81
+ return { ok: true, backend: undefined };
82
+ }
83
+
84
+ export function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined {
85
+ const selection = selectBackend(coreml, onnx);
86
+ if (!selection.ok) {
87
+ log.error(selection.error);
74
88
  process.exit(1);
75
89
  }
76
- if (coreml) return "coreml";
77
- if (onnx) return "onnx";
78
- return undefined;
90
+ return selection.backend;
79
91
  }
80
92
 
81
93
  function defaultBackendForPlatform(): string | undefined {
@@ -187,7 +199,7 @@ export const installCommand = defineCommand({
187
199
  },
188
200
  tts: {
189
201
  type: "boolean",
190
- description: "Also install TTS models (Kokoro EN + Vosk-TTS RU, ~990MB)",
202
+ description: "Also install TTS models (English only; add codes for more, e.g. --tts en ru (preview sizes: kesha install --plan))",
191
203
  default: false,
192
204
  },
193
205
  vad: {