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.
- package/.agentvibes/config.json +3 -38
- package/.claude/commands/agent-vibes/provider.md +0 -0
- package/.claude/config/audio-effects.cfg +1 -1
- package/.claude/config/background-music-position.txt +6 -8
- package/.claude/config/reverb-level.txt +0 -0
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/bmad-tts-injector.sh +49 -21
- package/.claude/hooks/migrate-to-agentvibes.sh +24 -16
- package/.claude/hooks/personality-manager.sh +15 -2
- package/.claude/hooks/play-tts.sh +6 -0
- package/.claude/hooks/provider-commands.sh +16 -4
- package/.claude/hooks/provider-manager.sh +38 -0
- package/.claude/hooks/stop.sh +2 -27
- package/.claude/hooks/voice-manager.sh +50 -2
- package/.claude/hooks-windows/play-tts.ps1 +34 -1
- package/.claude/hooks-windows/tts-watcher.ps1 +122 -0
- package/.claude/piper-voices-dir.txt +1 -1
- package/.mcp.json +13 -33
- package/README.md +6 -8
- package/RELEASE_NOTES.md +32 -0
- package/bin/agent-vibes +39 -39
- package/package.json +1 -1
- package/src/bmad-detector.js +85 -71
- package/src/cli/list-personalities.js +110 -110
- package/src/cli/list-voices.js +114 -114
- package/src/commands/bmad-voices.js +394 -394
- package/src/commands/install-mcp.js +476 -476
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/console/widgets/personality-picker.js +213 -213
- package/src/i18n/de.js +202 -202
- package/src/i18n/es.js +202 -202
- package/src/i18n/fr.js +202 -202
- package/src/i18n/hi.js +202 -202
- package/src/i18n/ja.js +202 -202
- package/src/i18n/ko.js +202 -202
- package/src/i18n/pt.js +202 -202
- package/src/i18n/strings.js +54 -54
- package/src/i18n/zh-CN.js +202 -202
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +330 -64
- package/src/services/agent-voice-store.js +59 -12
- package/src/services/config-service.js +264 -264
- package/src/services/language-service.js +47 -47
- package/src/services/llm-provider-service.js +57 -12
- package/src/services/provider-service.js +143 -143
- package/src/utils/audio-duration-validator.js +298 -298
- package/src/utils/audio-format-validator.js +277 -277
- package/src/utils/dependency-checker.js +469 -469
- package/src/utils/file-ownership-verifier.js +358 -358
- package/src/utils/list-formatter.js +194 -194
- package/src/utils/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +136 -136
- package/src/utils/secure-music-storage.js +412 -412
- package/.agentvibes/LITE-MODE.md +0 -236
- package/.agentvibes/README.md +0 -136
- package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +0 -141
- package/.agentvibes/backups/agents/analyst_20260204_144958.md +0 -78
- package/.agentvibes/backups/agents/architect_20260204_144958.md +0 -72
- package/.agentvibes/backups/agents/dev_20260204_144958.md +0 -74
- package/.agentvibes/backups/agents/pm_20260204_144958.md +0 -72
- package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +0 -64
- package/.agentvibes/backups/agents/sm_20260204_144958.md +0 -87
- package/.agentvibes/backups/agents/tea_20260204_144958.md +0 -79
- package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +0 -82
- package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +0 -80
- package/.agentvibes/config/README-personality-defaults.md +0 -162
- package/.agentvibes/config/agentvibes.json +0 -1
- package/.agentvibes/config/mode.txt +0 -1
- package/.agentvibes/config/personality-voice-defaults.default.json +0 -21
- package/.agentvibes/config/save-audio.txt +0 -1
- package/.agentvibes/config/voice-metadata.json +0 -160
- package/.agentvibes/hooks/help.sh +0 -191
- package/.agentvibes/hooks/post-tool-use-lite.sh +0 -111
- package/.agentvibes/hooks/save-audio-manager.sh +0 -162
- package/.agentvibes/hooks/session-start-full-optimized.sh +0 -102
- package/.agentvibes/hooks/session-start-full.sh +0 -142
- package/.agentvibes/hooks/session-start-lite-v2.sh +0 -34
- package/.agentvibes/hooks/session-start-lite.sh +0 -29
- package/.agentvibes/hooks/stop-lite.sh +0 -115
- package/.agentvibes/hooks/switch-mode.sh +0 -215
- package/.agentvibes/output-styles/audio-summary.md +0 -30
- package/.claude/audio/voice-samples/piper/alan.wav +0 -0
- package/.claude/audio/voice-samples/piper/amy.wav +0 -0
- package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
- package/.claude/audio/voice-samples/piper/joe.wav +0 -0
- package/.claude/audio/voice-samples/piper/john.wav +0 -0
- package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
- package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
- package/.claude/audio/voice-samples/piper/linda.wav +0 -0
- package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
- package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
- package/.claude/hooks/post-response.sh +0 -41
- 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
|
|
490
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
588
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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;
|