agentvibes 5.6.9 → 5.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.agentvibes/config.json +3 -38
  2. package/.claude/commands/agent-vibes/provider.md +0 -0
  3. package/.claude/config/audio-effects.cfg +1 -1
  4. package/.claude/config/background-music-position.txt +6 -8
  5. package/.claude/config/reverb-level.txt +0 -0
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/bmad-tts-injector.sh +49 -21
  8. package/.claude/hooks/migrate-to-agentvibes.sh +24 -16
  9. package/.claude/hooks/personality-manager.sh +15 -2
  10. package/.claude/hooks/play-tts.sh +6 -0
  11. package/.claude/hooks/provider-commands.sh +16 -4
  12. package/.claude/hooks/provider-manager.sh +38 -0
  13. package/.claude/hooks/stop.sh +2 -27
  14. package/.claude/hooks/voice-manager.sh +50 -2
  15. package/.claude/hooks-windows/play-tts.ps1 +34 -1
  16. package/.claude/hooks-windows/tts-watcher.ps1 +122 -0
  17. package/.claude/piper-voices-dir.txt +1 -1
  18. package/.mcp.json +13 -33
  19. package/README.md +6 -8
  20. package/RELEASE_NOTES.md +32 -0
  21. package/bin/agent-vibes +39 -39
  22. package/package.json +1 -1
  23. package/src/bmad-detector.js +85 -71
  24. package/src/cli/list-personalities.js +110 -110
  25. package/src/cli/list-voices.js +114 -114
  26. package/src/commands/bmad-voices.js +394 -394
  27. package/src/commands/install-mcp.js +476 -476
  28. package/src/console/brand-colors.js +13 -13
  29. package/src/console/constants/personalities.js +44 -44
  30. package/src/console/tabs/help-tab.js +314 -314
  31. package/src/console/tabs/readme-tab.js +272 -272
  32. package/src/console/widgets/destroy-list.js +25 -25
  33. package/src/console/widgets/notice.js +55 -55
  34. package/src/console/widgets/personality-picker.js +213 -213
  35. package/src/i18n/de.js +202 -202
  36. package/src/i18n/es.js +202 -202
  37. package/src/i18n/fr.js +202 -202
  38. package/src/i18n/hi.js +202 -202
  39. package/src/i18n/ja.js +202 -202
  40. package/src/i18n/ko.js +202 -202
  41. package/src/i18n/pt.js +202 -202
  42. package/src/i18n/strings.js +54 -54
  43. package/src/i18n/zh-CN.js +202 -202
  44. package/src/installer/language-screen.js +31 -31
  45. package/src/installer/music-file-input.js +304 -304
  46. package/src/installer.js +330 -64
  47. package/src/services/agent-voice-store.js +59 -12
  48. package/src/services/config-service.js +264 -264
  49. package/src/services/language-service.js +47 -47
  50. package/src/services/llm-provider-service.js +57 -12
  51. package/src/services/provider-service.js +143 -143
  52. package/src/utils/audio-duration-validator.js +298 -298
  53. package/src/utils/audio-format-validator.js +277 -277
  54. package/src/utils/dependency-checker.js +469 -469
  55. package/src/utils/file-ownership-verifier.js +358 -358
  56. package/src/utils/list-formatter.js +194 -194
  57. package/src/utils/music-file-validator.js +285 -285
  58. package/src/utils/preview-list-prompt.js +136 -136
  59. package/src/utils/secure-music-storage.js +412 -412
  60. package/.agentvibes/LITE-MODE.md +0 -236
  61. package/.agentvibes/README.md +0 -136
  62. package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +0 -141
  63. package/.agentvibes/backups/agents/analyst_20260204_144958.md +0 -78
  64. package/.agentvibes/backups/agents/architect_20260204_144958.md +0 -72
  65. package/.agentvibes/backups/agents/dev_20260204_144958.md +0 -74
  66. package/.agentvibes/backups/agents/pm_20260204_144958.md +0 -72
  67. package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +0 -64
  68. package/.agentvibes/backups/agents/sm_20260204_144958.md +0 -87
  69. package/.agentvibes/backups/agents/tea_20260204_144958.md +0 -79
  70. package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +0 -82
  71. package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +0 -80
  72. package/.agentvibes/config/README-personality-defaults.md +0 -162
  73. package/.agentvibes/config/agentvibes.json +0 -1
  74. package/.agentvibes/config/mode.txt +0 -1
  75. package/.agentvibes/config/personality-voice-defaults.default.json +0 -21
  76. package/.agentvibes/config/save-audio.txt +0 -1
  77. package/.agentvibes/config/voice-metadata.json +0 -160
  78. package/.agentvibes/hooks/help.sh +0 -191
  79. package/.agentvibes/hooks/post-tool-use-lite.sh +0 -111
  80. package/.agentvibes/hooks/save-audio-manager.sh +0 -162
  81. package/.agentvibes/hooks/session-start-full-optimized.sh +0 -102
  82. package/.agentvibes/hooks/session-start-full.sh +0 -142
  83. package/.agentvibes/hooks/session-start-lite-v2.sh +0 -34
  84. package/.agentvibes/hooks/session-start-lite.sh +0 -29
  85. package/.agentvibes/hooks/stop-lite.sh +0 -115
  86. package/.agentvibes/hooks/switch-mode.sh +0 -215
  87. package/.agentvibes/output-styles/audio-summary.md +0 -30
  88. package/.claude/audio/voice-samples/piper/alan.wav +0 -0
  89. package/.claude/audio/voice-samples/piper/amy.wav +0 -0
  90. package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
  91. package/.claude/audio/voice-samples/piper/joe.wav +0 -0
  92. package/.claude/audio/voice-samples/piper/john.wav +0 -0
  93. package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
  94. package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
  95. package/.claude/audio/voice-samples/piper/linda.wav +0 -0
  96. package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
  97. package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
  98. package/.claude/hooks/post-response.sh +0 -41
  99. package/bin/ensure-soprano-running.sh +0 -43
@@ -1,47 +1,47 @@
1
- /**
2
- * LanguageService — single source of truth for the selected UI language.
3
- *
4
- * Persists the selection to ~/.claude/config/language.txt so it survives
5
- * process restarts. Notifies registered listeners on every change so the
6
- * TUI can re-render dynamic labels immediately.
7
- */
8
-
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import os from 'node:os';
12
- import { t, SUPPORTED_LANGUAGES } from '../i18n/strings.js';
13
-
14
- const LANG_FILE = path.join(os.homedir(), '.claude', 'config', 'language.txt');
15
- const VALID_LANGS = new Set(SUPPORTED_LANGUAGES.map(l => l.value));
16
-
17
- export class LanguageService {
18
- constructor() {
19
- this._lang = this._load();
20
- this._listeners = [];
21
- }
22
-
23
- _load() {
24
- try {
25
- const val = fs.readFileSync(LANG_FILE, 'utf8').trim();
26
- return VALID_LANGS.has(val) ? val : 'en';
27
- } catch {
28
- return 'en';
29
- }
30
- }
31
-
32
- getLang() { return this._lang; }
33
-
34
- setLang(lang) {
35
- if (!VALID_LANGS.has(lang)) return;
36
- this._lang = lang;
37
- try {
38
- fs.mkdirSync(path.dirname(LANG_FILE), { recursive: true });
39
- fs.writeFileSync(LANG_FILE, lang, { mode: 0o600 });
40
- } catch { /* non-fatal */ }
41
- this._listeners.forEach(fn => fn(lang));
42
- }
43
-
44
- onChange(fn) { this._listeners.push(fn); }
45
-
46
- t(key) { return t(this._lang, key); }
47
- }
1
+ /**
2
+ * LanguageService — single source of truth for the selected UI language.
3
+ *
4
+ * Persists the selection to ~/.claude/config/language.txt so it survives
5
+ * process restarts. Notifies registered listeners on every change so the
6
+ * TUI can re-render dynamic labels immediately.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import { t, SUPPORTED_LANGUAGES } from '../i18n/strings.js';
13
+
14
+ const LANG_FILE = path.join(os.homedir(), '.claude', 'config', 'language.txt');
15
+ const VALID_LANGS = new Set(SUPPORTED_LANGUAGES.map(l => l.value));
16
+
17
+ export class LanguageService {
18
+ constructor() {
19
+ this._lang = this._load();
20
+ this._listeners = [];
21
+ }
22
+
23
+ _load() {
24
+ try {
25
+ const val = fs.readFileSync(LANG_FILE, 'utf8').trim();
26
+ return VALID_LANGS.has(val) ? val : 'en';
27
+ } catch {
28
+ return 'en';
29
+ }
30
+ }
31
+
32
+ getLang() { return this._lang; }
33
+
34
+ setLang(lang) {
35
+ if (!VALID_LANGS.has(lang)) return;
36
+ this._lang = lang;
37
+ try {
38
+ fs.mkdirSync(path.dirname(LANG_FILE), { recursive: true });
39
+ fs.writeFileSync(LANG_FILE, lang, { mode: 0o600 });
40
+ } catch { /* non-fatal */ }
41
+ this._listeners.forEach(fn => fn(lang));
42
+ }
43
+
44
+ onChange(fn) { this._listeners.push(fn); }
45
+
46
+ t(key) { return t(this._lang, key); }
47
+ }
@@ -481,19 +481,54 @@ export async function removeCopilotMcp(targetDir) {
481
481
  }
482
482
  }
483
483
 
484
+ const AGENTVIBES_MARKER_START = '<!-- BEGIN AGENTVIBES -->';
485
+ const AGENTVIBES_MARKER_END = '<!-- END AGENTVIBES -->';
486
+
487
+ function wrapWithMarkers(content) {
488
+ return `${AGENTVIBES_MARKER_START}\n${content.trim()}\n${AGENTVIBES_MARKER_END}\n`;
489
+ }
490
+
491
+ function injectMarkerBlock(existing, newBlock) {
492
+ const start = existing.indexOf(AGENTVIBES_MARKER_START);
493
+ const end = existing.indexOf(AGENTVIBES_MARKER_END);
494
+ if (start !== -1 && end !== -1) {
495
+ return existing.substring(0, start) + newBlock + existing.substring(end + AGENTVIBES_MARKER_END.length + 1);
496
+ }
497
+ return existing.trimEnd() + (existing.trim() ? '\n\n' : '') + newBlock;
498
+ }
499
+
500
+ function removeMarkerBlock(existing) {
501
+ const start = existing.indexOf(AGENTVIBES_MARKER_START);
502
+ const end = existing.indexOf(AGENTVIBES_MARKER_END);
503
+ if (start === -1 || end === -1) return existing;
504
+ const before = existing.substring(0, start).trimEnd();
505
+ const after = existing.substring(end + AGENTVIBES_MARKER_END.length).replace(/^\n+/, '\n');
506
+ return (before + after).trimEnd() + '\n';
507
+ }
508
+
484
509
  export async function installCopilotInstructions(targetDir, packageDir) {
485
510
  const destPath = path.join(targetDir, '.github', 'copilot-instructions.md');
486
511
  const srcPath = path.join(packageDir, '.github', 'copilot-instructions.md');
487
512
  try {
488
513
  await fs.mkdir(path.join(targetDir, '.github'), { recursive: true });
489
- const content = await fs.readFile(srcPath, 'utf8');
490
- await fs.writeFile(destPath, content);
514
+ const srcContent = await fs.readFile(srcPath, 'utf8');
515
+ let existing = '';
516
+ try { existing = await fs.readFile(destPath, 'utf8'); } catch { /* new file */ }
517
+ const block = wrapWithMarkers(srcContent);
518
+ await fs.writeFile(destPath, injectMarkerBlock(existing, block));
491
519
  } catch { /* best effort */ }
492
520
  }
493
521
 
494
522
  export async function removeCopilotInstructions(targetDir) {
523
+ const destPath = path.join(targetDir, '.github', 'copilot-instructions.md');
495
524
  try {
496
- await fs.unlink(path.join(targetDir, '.github', 'copilot-instructions.md'));
525
+ const existing = await fs.readFile(destPath, 'utf8');
526
+ const updated = removeMarkerBlock(existing);
527
+ if (updated.trim()) {
528
+ await fs.writeFile(destPath, updated);
529
+ } else {
530
+ await fs.unlink(destPath);
531
+ }
497
532
  } catch { /* already gone */ }
498
533
  }
499
534
 
@@ -582,10 +617,15 @@ export function buildCodexToml(existingContent = '') {
582
617
  export async function installCodexInstructions(targetDir, packageDir) {
583
618
  const srcPath = path.join(packageDir, '.codex', 'AGENTS.md');
584
619
  try {
585
- const content = await fs.readFile(srcPath, 'utf8');
620
+ const srcContent = await fs.readFile(srcPath, 'utf8');
621
+ const block = wrapWithMarkers(srcContent);
586
622
  await fs.mkdir(path.join(targetDir, '.codex'), { recursive: true });
587
- await fs.writeFile(path.join(targetDir, '.codex', 'AGENTS.md'), content);
588
- await fs.writeFile(path.join(targetDir, 'AGENTS.md'), content);
623
+
624
+ for (const dest of [path.join(targetDir, '.codex', 'AGENTS.md'), path.join(targetDir, 'AGENTS.md')]) {
625
+ let existing = '';
626
+ try { existing = await fs.readFile(dest, 'utf8'); } catch { /* new file */ }
627
+ await fs.writeFile(dest, injectMarkerBlock(existing, block));
628
+ }
589
629
  } catch { /* best effort */ }
590
630
  }
591
631
 
@@ -604,12 +644,17 @@ export async function installCodexHooks(targetDir, packageDir) {
604
644
  }
605
645
 
606
646
  export async function removeCodexInstructions(targetDir) {
607
- try {
608
- await fs.unlink(path.join(targetDir, '.codex', 'AGENTS.md'));
609
- } catch { /* already gone */ }
610
- try {
611
- await fs.unlink(path.join(targetDir, 'AGENTS.md'));
612
- } catch { /* already gone */ }
647
+ for (const dest of [path.join(targetDir, '.codex', 'AGENTS.md'), path.join(targetDir, 'AGENTS.md')]) {
648
+ try {
649
+ const existing = await fs.readFile(dest, 'utf8');
650
+ const updated = removeMarkerBlock(existing);
651
+ if (updated.trim()) {
652
+ await fs.writeFile(dest, updated);
653
+ } else {
654
+ await fs.unlink(dest);
655
+ }
656
+ } catch { /* already gone */ }
657
+ }
613
658
  }
614
659
 
615
660
  export async function removeCodexHooks(targetDir) {
@@ -1,143 +1,143 @@
1
- /**
2
- * AgentVibes Provider Service
3
- * Story 7.1: Provider & Voice Settings Group
4
- *
5
- * Detects installed TTS providers, reads/writes active provider and voice
6
- * through ConfigService. Gracefully degrades when detection fails.
7
- */
8
-
9
- import { execFileSync } from 'node:child_process';
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import os from 'node:os';
13
-
14
- export class ProviderService {
15
- /**
16
- * @param {import('./config-service.js').ConfigService} configService
17
- */
18
- constructor(configService) {
19
- this._config = configService;
20
- this._installedProviders = null; // cached after first detection
21
- }
22
-
23
- // ---------------------------------------------------------------------------
24
- // Provider
25
-
26
- /**
27
- * Returns the currently active TTS provider from config.
28
- * Defaults to 'piper' if not configured.
29
- * @returns {string}
30
- */
31
- getActiveProvider() {
32
- return this._config.getConfig().provider ?? 'piper';
33
- }
34
-
35
- /**
36
- * Sets the active TTS provider in config AND syncs to .claude/tts-provider.txt
37
- * so the shell hooks (play-tts.sh → provider-manager.sh) pick up the change.
38
- * @param {string} provider
39
- */
40
- setActiveProvider(provider) {
41
- this._config.set('provider', provider);
42
- this._config.setGlobal('provider', provider);
43
- this._syncProviderFile(provider);
44
- }
45
-
46
- /**
47
- * Write provider to .claude/tts-provider.txt so shell hooks stay in sync.
48
- * Writes to projectRoot/.claude/tts-provider.txt if .claude/ exists there,
49
- * otherwise falls back to ~/.claude/tts-provider.txt.
50
- * @param {string} provider
51
- */
52
- _syncProviderFile(provider) {
53
- try {
54
- const projectClaudeDir = path.resolve(this._config.getProjectRoot(), '.claude');
55
- const targetDir = fs.existsSync(projectClaudeDir)
56
- ? projectClaudeDir
57
- : path.resolve(os.homedir(), '.claude');
58
- const targetFile = path.resolve(targetDir, 'tts-provider.txt');
59
- // Verify resolved path stays within targetDir (path traversal guard)
60
- if (!targetFile.startsWith(targetDir + path.sep) && targetFile !== targetDir) return;
61
- fs.mkdirSync(targetDir, { recursive: true });
62
- fs.writeFileSync(targetFile, provider, 'utf8');
63
- } catch {
64
- // Non-fatal — config.json is the authoritative source
65
- }
66
- }
67
-
68
- /**
69
- * Returns an array of installed/available TTS providers.
70
- * Detection uses `which` binary check. Always returns at least ['piper']
71
- * as graceful degradation (piper is the primary supported provider).
72
- * @returns {string[]}
73
- */
74
- getInstalledProviders() {
75
- if (this._installedProviders) return this._installedProviders;
76
-
77
- const providers = [];
78
-
79
- if (this._isAvailable('piper')) providers.push('piper');
80
- if (this._isAvailable('soprano')) providers.push('soprano');
81
-
82
- // macOS Say (darwin only)
83
- if (process.platform === 'darwin' && this._isAvailable('say')) {
84
- providers.push('macos');
85
- }
86
-
87
- // Graceful degradation: always return at least piper
88
- if (providers.length === 0) providers.push('piper');
89
-
90
- this._installedProviders = providers;
91
- return providers;
92
- }
93
-
94
- // ---------------------------------------------------------------------------
95
- // Voice
96
-
97
- /**
98
- * Returns the currently active voice ID from config.
99
- * Falls back to first installed voice if not configured.
100
- * @returns {string|null}
101
- */
102
- getActiveVoiceId() {
103
- const voice = this._config.getConfig().voice;
104
- if (voice) return voice;
105
- // Detect first installed voice instead of hardcoding a default that may not exist
106
- const voicesDir = path.join(os.homedir(), '.claude', 'piper-voices');
107
- try {
108
- const models = fs.readdirSync(voicesDir).filter(f => f.endsWith('.onnx'));
109
- if (models.length > 0) return models[0].replace(/\.onnx$/, '');
110
- } catch { /* dir may not exist */ }
111
- return null;
112
- }
113
-
114
- /**
115
- * Sets the active voice ID in config.
116
- * Writes to both project (if exists) and global config for portability.
117
- * @param {string} voiceId
118
- */
119
- setActiveVoice(voiceId) {
120
- this._config.set('voice', voiceId);
121
- this._config.setGlobal('voice', voiceId);
122
- }
123
-
124
- // ---------------------------------------------------------------------------
125
- // Private
126
-
127
- /**
128
- * Check if a binary is available in PATH using `which`.
129
- * Binary names are all hardcoded (not user input) — safe from injection.
130
- * @param {string} binary - hardcoded binary name ('piper', 'soprano', 'say')
131
- * @returns {boolean}
132
- */
133
- _isAvailable(binary) {
134
- try {
135
- execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 });
136
- return true;
137
- } catch {
138
- return false;
139
- }
140
- }
141
- }
142
-
143
- export default ProviderService;
1
+ /**
2
+ * AgentVibes Provider Service
3
+ * Story 7.1: Provider & Voice Settings Group
4
+ *
5
+ * Detects installed TTS providers, reads/writes active provider and voice
6
+ * through ConfigService. Gracefully degrades when detection fails.
7
+ */
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+
14
+ export class ProviderService {
15
+ /**
16
+ * @param {import('./config-service.js').ConfigService} configService
17
+ */
18
+ constructor(configService) {
19
+ this._config = configService;
20
+ this._installedProviders = null; // cached after first detection
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Provider
25
+
26
+ /**
27
+ * Returns the currently active TTS provider from config.
28
+ * Defaults to 'piper' if not configured.
29
+ * @returns {string}
30
+ */
31
+ getActiveProvider() {
32
+ return this._config.getConfig().provider ?? 'piper';
33
+ }
34
+
35
+ /**
36
+ * Sets the active TTS provider in config AND syncs to .claude/tts-provider.txt
37
+ * so the shell hooks (play-tts.sh → provider-manager.sh) pick up the change.
38
+ * @param {string} provider
39
+ */
40
+ setActiveProvider(provider) {
41
+ this._config.set('provider', provider);
42
+ this._config.setGlobal('provider', provider);
43
+ this._syncProviderFile(provider);
44
+ }
45
+
46
+ /**
47
+ * Write provider to .claude/tts-provider.txt so shell hooks stay in sync.
48
+ * Writes to projectRoot/.claude/tts-provider.txt if .claude/ exists there,
49
+ * otherwise falls back to ~/.claude/tts-provider.txt.
50
+ * @param {string} provider
51
+ */
52
+ _syncProviderFile(provider) {
53
+ try {
54
+ const projectClaudeDir = path.resolve(this._config.getProjectRoot(), '.claude');
55
+ const targetDir = fs.existsSync(projectClaudeDir)
56
+ ? projectClaudeDir
57
+ : path.resolve(os.homedir(), '.claude');
58
+ const targetFile = path.resolve(targetDir, 'tts-provider.txt');
59
+ // Verify resolved path stays within targetDir (path traversal guard)
60
+ if (!targetFile.startsWith(targetDir + path.sep) && targetFile !== targetDir) return;
61
+ fs.mkdirSync(targetDir, { recursive: true });
62
+ fs.writeFileSync(targetFile, provider, 'utf8');
63
+ } catch {
64
+ // Non-fatal — config.json is the authoritative source
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Returns an array of installed/available TTS providers.
70
+ * Detection uses `which` binary check. Always returns at least ['piper']
71
+ * as graceful degradation (piper is the primary supported provider).
72
+ * @returns {string[]}
73
+ */
74
+ getInstalledProviders() {
75
+ if (this._installedProviders) return this._installedProviders;
76
+
77
+ const providers = [];
78
+
79
+ if (this._isAvailable('piper')) providers.push('piper');
80
+ if (this._isAvailable('soprano')) providers.push('soprano');
81
+
82
+ // macOS Say (darwin only)
83
+ if (process.platform === 'darwin' && this._isAvailable('say')) {
84
+ providers.push('macos');
85
+ }
86
+
87
+ // Graceful degradation: always return at least piper
88
+ if (providers.length === 0) providers.push('piper');
89
+
90
+ this._installedProviders = providers;
91
+ return providers;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Voice
96
+
97
+ /**
98
+ * Returns the currently active voice ID from config.
99
+ * Falls back to first installed voice if not configured.
100
+ * @returns {string|null}
101
+ */
102
+ getActiveVoiceId() {
103
+ const voice = this._config.getConfig().voice;
104
+ if (voice) return voice;
105
+ // Detect first installed voice instead of hardcoding a default that may not exist
106
+ const voicesDir = path.join(os.homedir(), '.claude', 'piper-voices');
107
+ try {
108
+ const models = fs.readdirSync(voicesDir).filter(f => f.endsWith('.onnx'));
109
+ if (models.length > 0) return models[0].replace(/\.onnx$/, '');
110
+ } catch { /* dir may not exist */ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Sets the active voice ID in config.
116
+ * Writes to both project (if exists) and global config for portability.
117
+ * @param {string} voiceId
118
+ */
119
+ setActiveVoice(voiceId) {
120
+ this._config.set('voice', voiceId);
121
+ this._config.setGlobal('voice', voiceId);
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Private
126
+
127
+ /**
128
+ * Check if a binary is available in PATH using `which`.
129
+ * Binary names are all hardcoded (not user input) — safe from injection.
130
+ * @param {string} binary - hardcoded binary name ('piper', 'soprano', 'say')
131
+ * @returns {boolean}
132
+ */
133
+ _isAvailable(binary) {
134
+ try {
135
+ execFileSync('which', [binary], { stdio: 'ignore', timeout: 2000 });
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ }
142
+
143
+ export default ProviderService;