@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 +13 -5
- package/completions/kesha.fish +2 -2
- package/completions/kesha.zsh +2 -2
- package/docs/product-positioning.md +16 -7
- package/man/kesha.1 +2 -2
- package/package.json +7 -6
- package/src/cli/action-result.ts +18 -0
- package/src/cli/command-session.ts +86 -0
- package/src/cli/context.ts +108 -0
- package/src/cli/dispatch.ts +36 -99
- package/src/cli/init.ts +2 -2
- package/src/cli/install.ts +18 -6
- package/src/cli/logs.ts +93 -67
- package/src/cli/main.ts +235 -210
- package/src/cli/record.ts +23 -2
- package/src/cli/say.ts +145 -96
- package/src/cli/stats.ts +96 -77
- package/src/cli.ts +3 -1
- package/src/engine.ts +27 -10
- package/src/error-codes.ts +1 -0
- package/src/install-plan.ts +100 -76
- package/src/mcp/tools.ts +10 -3
- package/src/mcp/voices.ts +12 -7
- package/src/shell-artifacts.ts +2 -2
- package/src/synth.ts +2 -4
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 ~
|
|
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
|
|
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
|
|
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 #
|
|
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) —
|
|
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
|
package/completions/kesha.fish
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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'
|
package/completions/kesha.zsh
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 |
|
|
64
|
-
| Audio language detection | Supported | Not shipped | Supported |
|
|
65
|
-
| VAD | Supported with `kesha install --vad` | Not shipped | Supported with `kesha install --vad` |
|
|
66
|
-
| TTS: English Kokoro | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path |
|
|
67
|
-
| TTS: Russian Vosk-TTS | Supported | Not shipped | Supported |
|
|
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 |
|
|
72
|
-
| Hermes Agent integration | Supported through command providers | Not shipped | Supported through command providers |
|
|
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 (
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { runMain, type CommandDef } from "citty";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
|
-
import { 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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
71
|
+
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
72
|
+
const context = resolveCliContext(argv);
|
|
73
|
+
applyCliContext(context);
|
|
135
74
|
|
|
136
|
-
|
|
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
|
|
148
|
-
log.error(
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
237
|
+
description: "Preselect TTS models (English; more languages via kesha install --tts <langs>)",
|
|
238
238
|
default: false,
|
|
239
239
|
},
|
|
240
240
|
vad: {
|
package/src/cli/install.ts
CHANGED
|
@@ -68,14 +68,26 @@ export function resolveNoCacheFlag(
|
|
|
68
68
|
);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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: {
|