@drakulavich/kesha-voice-kit 1.24.7 → 1.25.0

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
@@ -57,6 +57,7 @@ kesha --json --timestamps audio.ogg # JSON with timestamped segments
57
57
  kesha --toon audio.ogg # compact LLM-friendly TOON
58
58
  kesha status # show installed backend info
59
59
  kesha status --disk # + recursive cache disk usage
60
+ kesha status --json # machine-readable, for scripts
60
61
  ```
61
62
 
62
63
  Multiple files get `head`-style headers; stdout is the transcript, stderr is errors — pipe-friendly:
@@ -28,7 +28,7 @@ _kesha_completion() {
28
28
  record) opts="--help -h --out --max-seconds --debug" ;;
29
29
  say) opts="--help -h --voice --lang --out --rate --list-voices --ssml --format --bitrate --sample-rate --no-expand-abbrev --verbose --debug" ;;
30
30
  stats) opts="--help -h --format" ;;
31
- status) opts="--help -h --disk" ;;
31
+ status) opts="--help -h --disk --json" ;;
32
32
  support-bundle) opts="--help -h --output --include-logs" ;;
33
33
  *) opts="--help -h --version -v --json --toon --timestamps --speakers --include-errors --verbose --format --lang --debug --vad --no-vad --quiet --no-color" ;;
34
34
  esac
@@ -86,6 +86,7 @@ complete -c kesha -n '__fish_seen_subcommand_from stats' -l format -r -d 'Export
86
86
  complete -c kesha -n '__fish_seen_subcommand_from status' -l help -d 'Show help'
87
87
  complete -c kesha -n '__fish_seen_subcommand_from status' -s h -d 'Show help'
88
88
  complete -c kesha -n '__fish_seen_subcommand_from status' -l disk -d 'Include recursive cache disk usage'
89
+ complete -c kesha -n '__fish_seen_subcommand_from status' -l json -d 'Output status as JSON'
89
90
  complete -c kesha -n '__fish_seen_subcommand_from support-bundle' -l help -d 'Show help'
90
91
  complete -c kesha -n '__fish_seen_subcommand_from support-bundle' -s h -d 'Show help'
91
92
  complete -c kesha -n '__fish_seen_subcommand_from support-bundle' -l output -r -d 'Write archive to this .tar.gz path'
@@ -121,7 +121,8 @@ _kesha() {
121
121
  status)
122
122
  _arguments '--help[Show help]' \
123
123
  '-h[Show help]' \
124
- '--disk[Include recursive cache disk usage]'
124
+ '--disk[Include recursive cache disk usage]' \
125
+ '--json[Output status as JSON]'
125
126
  ;;
126
127
  support-bundle)
127
128
  _arguments '--help[Show help]' \
package/man/kesha.1 CHANGED
@@ -200,6 +200,9 @@ Export format: json | csv
200
200
  .TP
201
201
  .B \-\-disk
202
202
  Include recursive cache disk usage
203
+ .TP
204
+ .B \-\-json
205
+ Output status as JSON
203
206
  .SS support-bundle
204
207
  .TP
205
208
  .B \-\-output
@@ -0,0 +1,43 @@
1
+ {
2
+ "asr": [
3
+ { "relPath": "models/parakeet-tdt-v3/encoder-model.onnx", "sizeBytes": 41770866 },
4
+ { "relPath": "models/parakeet-tdt-v3/encoder-model.onnx.data", "sizeBytes": 2435420160 },
5
+ { "relPath": "models/parakeet-tdt-v3/decoder_joint-model.onnx", "sizeBytes": 72520893 },
6
+ { "relPath": "models/parakeet-tdt-v3/nemo128.onnx", "sizeBytes": 139764 },
7
+ { "relPath": "models/parakeet-tdt-v3/vocab.txt", "sizeBytes": 93939 }
8
+ ],
9
+ "langId": [
10
+ { "relPath": "models/lang-id-ecapa/lang-id-ecapa.onnx", "sizeBytes": 759814 },
11
+ { "relPath": "models/lang-id-ecapa/lang-id-ecapa.onnx.data", "sizeBytes": 85327872 },
12
+ { "relPath": "models/lang-id-ecapa/labels.json", "sizeBytes": 646 }
13
+ ],
14
+ "vad": [
15
+ { "relPath": "models/silero-vad/silero_vad.onnx", "sizeBytes": 2327524 }
16
+ ],
17
+ "g2pCharsiu": [
18
+ { "relPath": "models/g2p/byt5-tiny/encoder_model.onnx", "sizeBytes": 12478704 },
19
+ { "relPath": "models/g2p/byt5-tiny/decoder_model.onnx", "sizeBytes": 11983268 },
20
+ { "relPath": "models/g2p/byt5-tiny/decoder_with_past_model.onnx", "sizeBytes": 5427260 }
21
+ ],
22
+ "kokoroGraph": { "relPath": "models/kokoro-82m/model.onnx", "sizeBytes": 325532387 },
23
+ "kokoroVoices": {
24
+ "en": { "relPath": "models/kokoro-82m/voices/am_michael.bin", "sizeBytes": 522240 },
25
+ "es": { "relPath": "models/kokoro-82m/voices/em_alex.bin", "sizeBytes": 522240 },
26
+ "fr": { "relPath": "models/kokoro-82m/voices/ff_siwis.bin", "sizeBytes": 522240 },
27
+ "it": { "relPath": "models/kokoro-82m/voices/im_nicola.bin", "sizeBytes": 522240 },
28
+ "pt": { "relPath": "models/kokoro-82m/voices/pm_alex.bin", "sizeBytes": 522240 }
29
+ },
30
+ "voskRu": [
31
+ { "relPath": "models/vosk-ru/model.onnx", "sizeBytes": 179314533 },
32
+ { "relPath": "models/vosk-ru/dictionary", "sizeBytes": 101431118 },
33
+ { "relPath": "models/vosk-ru/config.json", "sizeBytes": 1518 },
34
+ { "relPath": "models/vosk-ru/bert/model.onnx", "sizeBytes": 654361598 },
35
+ { "relPath": "models/vosk-ru/bert/vocab.txt", "sizeBytes": 1780720 }
36
+ ],
37
+ "diarize": [
38
+ { "relPath": "models/diarize/SortformerNvidiaLow_v2.mlpackage/Manifest.json", "sizeBytes": 617 },
39
+ { "relPath": "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/model.mlmodel", "sizeBytes": 7080357 },
40
+ { "relPath": "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/weights/0-weight.bin", "sizeBytes": 5930400 },
41
+ { "relPath": "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/weights/1-weight.bin", "sizeBytes": 232161600 }
42
+ ]
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.24.7",
3
+ "version": "1.25.0",
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": {
@@ -15,6 +15,7 @@
15
15
  "completions/",
16
16
  "man/",
17
17
  "src/",
18
+ "model-plan.json",
18
19
  "package.json",
19
20
  "tsconfig.json",
20
21
  "openclaw.plugin.json",
@@ -43,7 +44,7 @@
43
44
  "scripts": {
44
45
  "test": "bun test --timeout 30000",
45
46
  "test:watch": "bun test --timeout 30000 --watch",
46
- "test:unit": "bun test tests/unit/",
47
+ "test:unit": "bun test --timeout 30000 tests/unit/",
47
48
  "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
49
  "test:integration": "bun test --timeout 30000 tests/integration/",
49
50
  "coverage:ts": "bun run test:cli-fast --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage/ts",
@@ -92,7 +93,7 @@
92
93
  "dependencies": {
93
94
  "@clack/prompts": "^1.5.0",
94
95
  "@modelcontextprotocol/sdk": "^1.29.0",
95
- "@toon-format/toon": "^2.1.0",
96
+ "@toon-format/toon": "^4.1.0",
96
97
  "citty": "^0.2.2",
97
98
  "fastest-levenshtein": "^1.0.16",
98
99
  "picocolors": "^1.1.1",
package/src/cli/status.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { defineCommand } from "citty";
2
- import { showStatus } from "../status";
2
+ import { collectStatus, renderStatus } from "../status";
3
3
 
4
4
  interface StatusCommandArgs {
5
5
  disk: boolean;
6
+ json: boolean;
6
7
  }
7
8
 
8
9
  export const statusCommand = defineCommand({
@@ -16,8 +17,20 @@ export const statusCommand = defineCommand({
16
17
  description: "Include recursive cache disk usage",
17
18
  default: false,
18
19
  },
20
+ json: {
21
+ type: "boolean",
22
+ description: "Output status as JSON",
23
+ default: false,
24
+ },
19
25
  },
20
26
  async run({ args }: { args: StatusCommandArgs }) {
21
- await showStatus({ disk: args.disk });
27
+ const report = await collectStatus({ disk: args.disk });
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).
31
+ console.log(JSON.stringify(report, null, 2));
32
+ return;
33
+ }
34
+ renderStatus(report);
22
35
  },
23
36
  });
package/src/engine.ts CHANGED
@@ -364,10 +364,23 @@ export async function getEngineCapabilities(): Promise<EngineCapabilities | null
364
364
  const { stdout, exitCode } = await runEngine(["--capabilities-json"]);
365
365
  if (exitCode !== 0) return null;
366
366
  try {
367
- const capabilities = JSON.parse(stdout) as EngineCapabilities;
367
+ const capabilities = parseCapabilities(JSON.parse(stdout));
368
+ if (!capabilities) return null;
368
369
  cachedEngineCapabilities = { binPath, mtime, capabilities };
369
370
  return capabilities;
370
371
  } catch {
371
372
  return null;
372
373
  }
373
374
  }
375
+
376
+ // A non-null return has to mean "the engine described itself" — callers reach
377
+ // straight for `.features.join()` and `.backend` (#647). Only the fields those
378
+ // callers consume are checked, so a newer engine adding fields still validates.
379
+ function parseCapabilities(parsed: unknown): EngineCapabilities | null {
380
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
381
+ const { protocolVersion, backend, features } = parsed as Record<string, unknown>;
382
+ if (typeof protocolVersion !== "number") return null;
383
+ if (typeof backend !== "string") return null;
384
+ if (!Array.isArray(features) || features.some((f) => typeof f !== "string")) return null;
385
+ return parsed as EngineCapabilities;
386
+ }
@@ -11,6 +11,7 @@ import {
11
11
  fluidKokoroCachePath,
12
12
  isDarwinArm64,
13
13
  } from "./fluid-kokoro-cache";
14
+ import modelPlan from "../model-plan.json" with { type: "json" };
14
15
 
15
16
  export interface InstallPlanOptions {
16
17
  noCache?: boolean;
@@ -26,6 +27,17 @@ interface PlanFile {
26
27
  sizeBytes: number;
27
28
  }
28
29
 
30
+ interface ModelPlan {
31
+ asr: PlanFile[];
32
+ langId: PlanFile[];
33
+ vad: PlanFile[];
34
+ g2pCharsiu: PlanFile[];
35
+ kokoroGraph: PlanFile;
36
+ kokoroVoices: Record<string, PlanFile>;
37
+ voskRu: PlanFile[];
38
+ diarize: PlanFile[];
39
+ }
40
+
29
41
  interface PlanComponent {
30
42
  name: string;
31
43
  source: string;
@@ -45,46 +57,19 @@ interface ReleaseAssetSpec {
45
57
  sizeBytes: number;
46
58
  }
47
59
 
48
- // Mirrors the pinned runtime manifests so `kesha install --plan` works before
49
- // the engine exists. Keep in sync with rust/src/models.rs and release assets.
50
- const ASR_FILES: PlanFile[] = [
51
- { relPath: "models/parakeet-tdt-v3/encoder-model.onnx", sizeBytes: 41_770_866 },
52
- { relPath: "models/parakeet-tdt-v3/encoder-model.onnx.data", sizeBytes: 2_435_420_160 },
53
- { relPath: "models/parakeet-tdt-v3/decoder_joint-model.onnx", sizeBytes: 72_520_893 },
54
- { relPath: "models/parakeet-tdt-v3/nemo128.onnx", sizeBytes: 139_764 },
55
- { relPath: "models/parakeet-tdt-v3/vocab.txt", sizeBytes: 93_939 },
56
- ];
57
-
58
- const LANG_ID_FILES: PlanFile[] = [
59
- { relPath: "models/lang-id-ecapa/lang-id-ecapa.onnx", sizeBytes: 759_814 },
60
- { relPath: "models/lang-id-ecapa/lang-id-ecapa.onnx.data", sizeBytes: 85_327_872 },
61
- { relPath: "models/lang-id-ecapa/labels.json", sizeBytes: 646 },
62
- ];
63
-
64
- const VAD_FILES: PlanFile[] = [
65
- { relPath: "models/silero-vad/silero_vad.onnx", sizeBytes: 2_327_524 },
66
- ];
67
-
68
- // klebster CharsiuG2P byt5-tiny ONNX export (CC-BY 4.0 — see NOTICES.md). Multilingual G2P for es/fr/it/pt (#212).
69
- const G2P_CHARSIU_FILES: PlanFile[] = [
70
- { relPath: "models/g2p/byt5-tiny/encoder_model.onnx", sizeBytes: 12_478_704 },
71
- { relPath: "models/g2p/byt5-tiny/decoder_model.onnx", sizeBytes: 11_983_268 },
72
- { relPath: "models/g2p/byt5-tiny/decoder_with_past_model.onnx", sizeBytes: 5_427_260 },
73
- ];
74
-
75
- // Kokoro ONNX graph (shared by all Kokoro languages on non-darwin builds).
76
- const KOKORO_GRAPH_FILE: PlanFile = { relPath: "models/kokoro-82m/model.onnx", sizeBytes: 325_532_387 };
77
-
78
- // Multilingual voice packs for es/fr/it/pt (#212).
79
- // em_alex (es, male), ff_siwis (fr, female — only French voice in Kokoro v1.0),
80
- // im_nicola (it, male), pm_alex (pt, male).
81
- const KOKORO_VOICE_FILES: Record<string, PlanFile> = {
82
- en: { relPath: "models/kokoro-82m/voices/am_michael.bin", sizeBytes: 522_240 },
83
- es: { relPath: "models/kokoro-82m/voices/em_alex.bin", sizeBytes: 522_240 },
84
- fr: { relPath: "models/kokoro-82m/voices/ff_siwis.bin", sizeBytes: 522_240 },
85
- it: { relPath: "models/kokoro-82m/voices/im_nicola.bin", sizeBytes: 522_240 },
86
- pt: { relPath: "models/kokoro-82m/voices/pm_alex.bin", sizeBytes: 522_240 },
87
- };
60
+ // Shared plan metadata keeps `kesha install --plan` usable before the engine
61
+ // exists. Rust tests guard these cache paths against its pinned manifests.
62
+ const plan: ModelPlan = modelPlan;
63
+ const {
64
+ asr: ASR_FILES,
65
+ langId: LANG_ID_FILES,
66
+ vad: VAD_FILES,
67
+ g2pCharsiu: G2P_CHARSIU_FILES,
68
+ kokoroGraph: KOKORO_GRAPH_FILE,
69
+ kokoroVoices: KOKORO_VOICE_FILES,
70
+ voskRu: VOSK_RU_FILES,
71
+ diarize: DIARIZE_FILES,
72
+ } = plan;
88
73
 
89
74
  function kokoroPlanFiles(langs: string[]): PlanFile[] {
90
75
  const files: PlanFile[] = [KOKORO_GRAPH_FILE];
@@ -95,30 +80,6 @@ function kokoroPlanFiles(langs: string[]): PlanFile[] {
95
80
  return files;
96
81
  }
97
82
 
98
- const VOSK_RU_FILES: PlanFile[] = [
99
- { relPath: "models/vosk-ru/model.onnx", sizeBytes: 179_314_533 },
100
- { relPath: "models/vosk-ru/dictionary", sizeBytes: 101_431_118 },
101
- { relPath: "models/vosk-ru/config.json", sizeBytes: 1_518 },
102
- { relPath: "models/vosk-ru/bert/model.onnx", sizeBytes: 654_361_598 },
103
- { relPath: "models/vosk-ru/bert/vocab.txt", sizeBytes: 1_780_720 },
104
- ];
105
-
106
- const DIARIZE_FILES: PlanFile[] = [
107
- { relPath: "models/diarize/SortformerNvidiaLow_v2.mlpackage/Manifest.json", sizeBytes: 617 },
108
- {
109
- relPath: "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/model.mlmodel",
110
- sizeBytes: 7_080_357,
111
- },
112
- {
113
- relPath: "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/weights/0-weight.bin",
114
- sizeBytes: 5_930_400,
115
- },
116
- {
117
- relPath: "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/weights/1-weight.bin",
118
- sizeBytes: 232_161_600,
119
- },
120
- ];
121
-
122
83
  // The sidecar list (and the asset-name → installed-filename mapping) lives in
123
84
  // SIDECARS in engine-install.ts; only the release-asset sizes are pinned here,
124
85
  // like the model tables above.
package/src/status.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { readdirSync, statSync } from "fs";
2
2
  import { join } from "path";
3
- import { isEngineInstalled, getEngineBinPath, getEngineCapabilities } from "./engine";
3
+ import {
4
+ isEngineInstalled,
5
+ getEngineBinPath,
6
+ getEngineCapabilities,
7
+ type EngineCapabilities,
8
+ } from "./engine";
4
9
  import { installHint } from "./install-hint";
5
10
  import { log } from "./log";
11
+ import { packageVersion } from "./package-info";
6
12
  import { keshaCacheDir } from "./paths";
7
13
  import { fluidKokoroCacheInfo } from "./fluid-kokoro-cache";
8
14
  import { dirSizeBytes } from "./diagnostic-paths";
@@ -35,8 +41,60 @@ export interface ShowStatusOptions {
35
41
  disk?: boolean;
36
42
  }
37
43
 
38
- async function logEngineCapabilities(): Promise<void> {
39
- const caps = await getEngineCapabilities().catch(() => null);
44
+ export interface StatusDiskComponent {
45
+ label: string;
46
+ sizeBytes: number;
47
+ }
48
+
49
+ export interface StatusDiskUsage {
50
+ cachePath: string;
51
+ components: StatusDiskComponent[];
52
+ componentTotalBytes: number;
53
+ totalBytes: number;
54
+ fluidKokoro: { path: string; sizeBytes: number } | null;
55
+ }
56
+
57
+ /**
58
+ * Machine-readable `kesha status` payload (#647). Contract: every key is always
59
+ * present — absent values are null (or `[]` for voices), never omitted, so a
60
+ * consumer never distinguishes a missing key from a null value. `engine.installed`
61
+ * reports only that a binary exists; usability additionally requires
62
+ * `engine.capabilities` to be non-null.
63
+ */
64
+ export interface StatusReport {
65
+ cliVersion: string;
66
+ engine: {
67
+ installed: boolean;
68
+ path: string;
69
+ capabilities: EngineCapabilities | null;
70
+ };
71
+ voices: string[];
72
+ runtime: { bun: string; platform: string; arch: string };
73
+ modelMirror: string | null;
74
+ hint: string | null;
75
+ disk: StatusDiskUsage | null;
76
+ }
77
+
78
+ export async function collectStatus(options: ShowStatusOptions = {}): Promise<StatusReport> {
79
+ const path = getEngineBinPath();
80
+ const installed = isEngineInstalled();
81
+ const capabilities = installed ? await getEngineCapabilities().catch(() => null) : null;
82
+
83
+ return {
84
+ cliVersion: packageVersion,
85
+ engine: { installed, path, capabilities },
86
+ voices: installed ? listInstalledVoices() : [],
87
+ runtime: { bun: Bun.version, platform: process.platform, arch: process.arch },
88
+ modelMirror: activeModelMirror(),
89
+ hint: installed
90
+ ? null
91
+ : `Run \`${installHint()}\` to download the engine and models.`,
92
+ // Absent engine means no disk walk, matching the human path (#647).
93
+ disk: installed && options.disk ? collectDiskUsage(path) : null,
94
+ };
95
+ }
96
+
97
+ function logEngineCapabilities(caps: EngineCapabilities | null): void {
40
98
  if (caps) {
41
99
  log.info(formatStatusLine("Backend", caps.backend, true));
42
100
  log.info(formatStatusLine("Protocol", `v${caps.protocolVersion}`, true));
@@ -46,8 +104,7 @@ async function logEngineCapabilities(): Promise<void> {
46
104
  }
47
105
  }
48
106
 
49
- function logInstalledVoices(): void {
50
- const voices = listInstalledVoices();
107
+ function logInstalledVoices(voices: string[]): void {
51
108
  if (voices.length === 0) return;
52
109
  log.info("TTS voices:");
53
110
  for (const v of voices) {
@@ -56,40 +113,37 @@ function logInstalledVoices(): void {
56
113
  log.info("");
57
114
  }
58
115
 
59
- export async function showStatus(options: ShowStatusOptions = {}): Promise<void> {
60
- const binPath = getEngineBinPath();
61
- const installed = isEngineInstalled();
116
+ export function renderStatus(report: StatusReport): void {
117
+ const { installed, path, capabilities } = report.engine;
62
118
 
63
119
  log.info("Engine:");
64
- log.info(formatStatusLine("Binary", installed ? binPath : null, installed));
120
+ log.info(formatStatusLine("Binary", installed ? path : null, installed));
65
121
 
66
122
  if (installed) {
67
- await logEngineCapabilities();
123
+ logEngineCapabilities(capabilities);
68
124
  }
69
125
  log.info("");
70
126
 
71
- log.info(formatStatusLine("Runtime", `Bun ${Bun.version}`, true));
72
- log.info(formatStatusLine("Platform", `${process.platform} ${process.arch}`, true));
73
- const mirror = activeModelMirror();
74
- if (mirror) {
75
- log.info(formatStatusLine("Mirror", mirror, true));
127
+ log.info(formatStatusLine("Runtime", `Bun ${report.runtime.bun}`, true));
128
+ log.info(
129
+ formatStatusLine("Platform", `${report.runtime.platform} ${report.runtime.arch}`, true),
130
+ );
131
+ if (report.modelMirror) {
132
+ log.info(formatStatusLine("Mirror", report.modelMirror, true));
76
133
  }
77
134
  log.info("");
78
135
 
79
136
  if (installed) {
80
- logInstalledVoices();
81
-
82
- if (options.disk) {
83
- showDiskUsage(binPath);
84
- }
137
+ logInstalledVoices(report.voices);
138
+ if (report.disk) showDiskUsage(report.disk);
85
139
  }
86
140
 
87
- if (!installed) {
88
- log.warn(`Run \`${installHint()}\` to download the engine and models.`);
89
- return;
141
+ if (report.hint) {
142
+ log.warn(report.hint);
90
143
  }
91
144
  }
92
145
 
146
+
93
147
  function buildDiskComponents(cache: string, engineDir: string): Array<{ label: string; path: string }> {
94
148
  return [
95
149
  { label: "Engine", path: engineDir },
@@ -101,11 +155,11 @@ function buildDiskComponents(cache: string, engineDir: string): Array<{ label: s
101
155
  ];
102
156
  }
103
157
 
104
- function logDiskRows(rows: Array<{ label: string; size: number }>, total: number, componentTotal: number): void {
158
+ function logDiskRows(rows: StatusDiskComponent[], total: number, componentTotal: number): void {
105
159
  const labelWidth = Math.max(...rows.map((r) => r.label.length), "Total".length);
106
160
  for (const r of rows) {
107
161
  const pad = " ".repeat(labelWidth - r.label.length + 2);
108
- log.info(` ${r.label}:${pad}${humanBytes(r.size)}`);
162
+ log.info(` ${r.label}:${pad}${humanBytes(r.sizeBytes)}`);
109
163
  }
110
164
  const totalPad = " ".repeat(labelWidth - "Total".length + 2);
111
165
  log.info(` ${pc.bold("Total")}:${totalPad}${pc.bold(humanBytes(total))}`);
@@ -115,42 +169,51 @@ function logDiskRows(rows: Array<{ label: string; size: number }>, total: number
115
169
  }
116
170
  }
117
171
 
118
- function logFluidKokoroCache(): void {
119
- const fluidKokoro = fluidKokoroCacheInfo();
120
- if (!fluidKokoro.exists || fluidKokoro.sizeBytes <= 0) return;
172
+ function logFluidKokoroCache(fluidKokoro: StatusDiskUsage["fluidKokoro"]): void {
173
+ if (!fluidKokoro) return;
121
174
  log.info("");
122
175
  log.info(`External caches (not included in Kesha total):`);
123
176
  log.info(` FluidAudio Kokoro: ${humanBytes(fluidKokoro.sizeBytes)} (${fluidKokoro.path})`);
124
177
  }
125
178
 
126
- function showDiskUsage(binPath: string): void {
179
+ function collectDiskUsage(binPath: string): StatusDiskUsage {
127
180
  const cache = keshaCacheDir();
128
181
  // Two levels up from the binary (`<cache>/engine/bin/`) so future engine-root siblings are counted.
129
182
  const engineDir = join(binPath, "..", "..");
130
183
 
131
- const components = buildDiskComponents(cache, engineDir);
132
-
133
- const rows: Array<{ label: string; size: number }> = [];
134
- for (const c of components) {
135
- const size = dirSizeBytes(c.path);
136
- if (size > 0) rows.push({ label: c.label, size });
184
+ const components: StatusDiskComponent[] = [];
185
+ for (const c of buildDiskComponents(cache, engineDir)) {
186
+ const sizeBytes = dirSizeBytes(c.path);
187
+ if (sizeBytes > 0) components.push({ label: c.label, sizeBytes });
137
188
  }
138
189
 
139
- if (rows.length === 0) return;
140
-
141
190
  // Sum cache root + engine dir separately so `KESHA_ENGINE_BIN` overrides outside the cache are still counted.
142
- const componentTotal = rows.reduce((n, r) => n + r.size, 0);
143
191
  const cacheTotal = dirSizeBytes(cache);
144
- const engineOutsideCache = engineDir.startsWith(cache)
145
- ? 0
146
- : dirSizeBytes(engineDir);
147
- const total = cacheTotal + engineOutsideCache;
148
-
149
- log.info(`Disk usage (${cache}):`);
150
- logDiskRows(rows, total, componentTotal);
151
- logFluidKokoroCache();
192
+ const engineOutsideCache = engineDir.startsWith(cache) ? 0 : dirSizeBytes(engineDir);
193
+ const fluidKokoro = fluidKokoroCacheInfo();
194
+
195
+ return {
196
+ cachePath: cache,
197
+ components,
198
+ componentTotalBytes: components.reduce((n, c) => n + c.sizeBytes, 0),
199
+ totalBytes: cacheTotal + engineOutsideCache,
200
+ fluidKokoro:
201
+ fluidKokoro.exists && fluidKokoro.sizeBytes > 0
202
+ ? { path: fluidKokoro.path, sizeBytes: fluidKokoro.sizeBytes }
203
+ : null,
204
+ };
205
+ }
206
+
207
+ function showDiskUsage(disk: StatusDiskUsage): void {
208
+ if (disk.components.length === 0) return;
209
+
210
+ log.info(`Disk usage (${disk.cachePath}):`);
211
+ logDiskRows(disk.components, disk.totalBytes, disk.componentTotalBytes);
212
+ logFluidKokoroCache(disk.fluidKokoro);
152
213
  log.info("");
153
- log.info(pc.dim(` To reset cache: rm -rf ${cache} — next \`kesha install\` re-downloads.`));
214
+ log.info(
215
+ pc.dim(` To reset cache: rm -rf ${disk.cachePath} — next \`kesha install\` re-downloads.`),
216
+ );
154
217
  log.info("");
155
218
  }
156
219