agentvibes 5.10.1 → 5.11.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.
Files changed (160) hide show
  1. package/.agentvibes/config.json +15 -1
  2. package/.agentvibes/install-manifest.json +342 -0
  3. package/.claude/audio/ui/CREDITS.txt +16 -0
  4. package/.claude/audio/ui/bling-success.wav +0 -0
  5. package/.claude/commands/agent-vibes/receiver.md +64 -0
  6. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  7. package/.claude/commands/agent-vibes-rdp.md +24 -24
  8. package/.claude/config/audio-effects.cfg +5 -4
  9. package/.claude/config/audio-effects.cfg.sample +52 -52
  10. package/.claude/config/background-music-enabled.txt +1 -1
  11. package/.claude/config/background-music-position.txt +27 -0
  12. package/.claude/config/background-music-volume.txt +1 -0
  13. package/.claude/config/background-music.cfg +1 -0
  14. package/.claude/config/background-music.txt +1 -0
  15. package/.claude/config/language.txt +1 -0
  16. package/.claude/config/personality.txt +1 -0
  17. package/.claude/config/reverb-level.txt +1 -0
  18. package/.claude/config/tts-speech-rate.txt +1 -0
  19. package/.claude/config/tts-verbosity.txt +1 -0
  20. package/.claude/docs/TERMUX_SETUP.md +408 -408
  21. package/.claude/github-star-reminder.txt +1 -1
  22. package/.claude/hooks/audio-cache-utils.sh +0 -0
  23. package/.claude/hooks/audio-processor.sh +57 -18
  24. package/.claude/hooks/background-music-manager.sh +0 -0
  25. package/.claude/hooks/bmad-party-speak.sh +0 -0
  26. package/.claude/hooks/bmad-speak-enhanced.sh +0 -0
  27. package/.claude/hooks/bmad-speak.sh +0 -0
  28. package/.claude/hooks/bmad-tts-injector.sh +0 -0
  29. package/.claude/hooks/bmad-voice-manager.sh +0 -0
  30. package/.claude/hooks/clawdbot-receiver-SECURE.sh +0 -0
  31. package/.claude/hooks/clawdbot-receiver.sh +0 -0
  32. package/.claude/hooks/clean-audio-cache.sh +0 -0
  33. package/.claude/hooks/cleanup-cache.sh +0 -0
  34. package/.claude/hooks/configure-rdp-mode.sh +0 -0
  35. package/.claude/hooks/download-extra-voices.sh +0 -0
  36. package/.claude/hooks/effects-manager.sh +0 -0
  37. package/.claude/hooks/github-star-reminder.sh +0 -0
  38. package/.claude/hooks/kokoro-installer.sh +117 -0
  39. package/.claude/hooks/kokoro-server.py +219 -0
  40. package/.claude/hooks/kokoro-tts.py +141 -0
  41. package/.claude/hooks/language-manager.sh +0 -0
  42. package/.claude/hooks/learn-manager.sh +0 -0
  43. package/.claude/hooks/macos-voice-manager.sh +0 -0
  44. package/.claude/hooks/migrate-background-music.sh +0 -0
  45. package/.claude/hooks/migrate-to-agentvibes.sh +0 -0
  46. package/.claude/hooks/optimize-background-music.sh +0 -0
  47. package/.claude/hooks/path-resolver.sh +0 -0
  48. package/.claude/hooks/personality-manager.sh +0 -0
  49. package/.claude/hooks/piper-download-voices.sh +39 -16
  50. package/.claude/hooks/piper-installer.sh +11 -6
  51. package/.claude/hooks/piper-multispeaker-registry.sh +0 -0
  52. package/.claude/hooks/piper-voice-manager.sh +6 -6
  53. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +3 -1
  54. package/.claude/hooks/play-tts-agentvibes-receiver.sh +1 -0
  55. package/.claude/hooks/play-tts-elevenlabs.sh +360 -0
  56. package/.claude/hooks/play-tts-enhanced.sh +0 -0
  57. package/.claude/hooks/play-tts-kokoro.sh +127 -0
  58. package/.claude/hooks/play-tts-macos.sh +0 -0
  59. package/.claude/hooks/play-tts-piper.sh +0 -0
  60. package/.claude/hooks/play-tts-soprano.sh +0 -0
  61. package/.claude/hooks/play-tts-ssh-remote.sh +9 -2
  62. package/.claude/hooks/play-tts-termux-ssh.sh +3 -1
  63. package/.claude/hooks/play-tts-windows-receiver.sh +0 -0
  64. package/.claude/hooks/play-tts.sh +78 -2
  65. package/.claude/hooks/prepare-release.sh +0 -0
  66. package/.claude/hooks/provider-commands.sh +71 -22
  67. package/.claude/hooks/provider-manager.sh +15 -7
  68. package/.claude/hooks/replay-target-audio.sh +0 -0
  69. package/.claude/hooks/requirements.txt +6 -6
  70. package/.claude/hooks/sentiment-manager.sh +0 -0
  71. package/.claude/hooks/session-start-tts.sh +0 -0
  72. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  73. package/.claude/hooks/speed-manager.sh +0 -0
  74. package/.claude/hooks/stop-tts.sh +0 -0
  75. package/.claude/hooks/termux-installer.sh +0 -0
  76. package/.claude/hooks/translate-manager.sh +0 -0
  77. package/.claude/hooks/translator.py +237 -237
  78. package/.claude/hooks/tts-queue-worker.sh +0 -0
  79. package/.claude/hooks/tts-queue.sh +0 -0
  80. package/.claude/hooks/verbosity-manager.sh +0 -0
  81. package/.claude/hooks/voice-manager.sh +0 -0
  82. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +119 -0
  83. package/.claude/hooks-windows/kokoro-server.py +219 -0
  84. package/.claude/hooks-windows/kokoro-tts.py +107 -0
  85. package/.claude/hooks-windows/play-tts-kokoro.ps1 +191 -0
  86. package/.claude/hooks-windows/play-tts.ps1 +29 -1
  87. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  88. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +153 -0
  89. package/.claude/piper-voices-dir.txt +1 -0
  90. package/.claude/verbosity.txt +1 -1
  91. package/.clawdbot/README.md +105 -105
  92. package/.mcp.json +6 -19
  93. package/README.md +12 -86
  94. package/RELEASE_NOTES.md +60 -0
  95. package/WINDOWS-SETUP.md +208 -208
  96. package/bin/agent-vibes +39 -39
  97. package/bin/agentvibes-voice-browser.js +0 -0
  98. package/bin/agentvibes.js +0 -0
  99. package/bin/mcp-server.js +121 -121
  100. package/bin/mcp-server.sh +0 -0
  101. package/bin/test-bmad-pr +78 -78
  102. package/mcp-server/QUICK_START.md +203 -203
  103. package/mcp-server/README.md +345 -345
  104. package/mcp-server/WINDOWS_SETUP.md +0 -0
  105. package/mcp-server/examples/claude_desktop_config.json +11 -11
  106. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  107. package/mcp-server/examples/custom_instructions.md +169 -169
  108. package/mcp-server/install-deps.js +0 -0
  109. package/mcp-server/server.py +1807 -1807
  110. package/mcp-server/test_server.py +0 -0
  111. package/package.json +4 -3
  112. package/src/cli/list-personalities.js +110 -110
  113. package/src/cli/list-voices.js +114 -114
  114. package/src/commands/bmad-voices.js +394 -394
  115. package/src/commands/install-mcp.js +3 -3
  116. package/src/console/app.js +25 -9
  117. package/src/console/audio-env.js +85 -1
  118. package/src/console/brand-colors.js +13 -13
  119. package/src/console/constants/personalities.js +44 -44
  120. package/src/console/navigation.js +4 -0
  121. package/src/console/tabs/agents-tab.js +12 -6
  122. package/src/console/tabs/help-tab.js +314 -314
  123. package/src/console/tabs/music-tab.js +7 -25
  124. package/src/console/tabs/readme-tab.js +272 -272
  125. package/src/console/tabs/setup-tab.js +1704 -120
  126. package/src/console/tabs/voices-tab.js +72 -88
  127. package/src/console/widgets/destroy-list.js +25 -25
  128. package/src/console/widgets/format-utils.js +14 -2
  129. package/src/console/widgets/help-bar.js +55 -0
  130. package/src/console/widgets/notice.js +55 -55
  131. package/src/console/widgets/reverb-picker.js +429 -41
  132. package/src/console/widgets/track-picker.js +60 -51
  133. package/src/i18n/de.js +2 -1
  134. package/src/i18n/en.js +1 -0
  135. package/src/i18n/es.js +2 -1
  136. package/src/i18n/fr.js +2 -1
  137. package/src/i18n/hi.js +2 -1
  138. package/src/i18n/ja.js +2 -1
  139. package/src/i18n/ko.js +2 -1
  140. package/src/i18n/pt.js +2 -1
  141. package/src/i18n/strings.js +9 -9
  142. package/src/i18n/zh-CN.js +2 -1
  143. package/src/installer/language-screen.js +31 -31
  144. package/src/installer/music-file-input.js +304 -304
  145. package/src/installer.js +95 -7
  146. package/src/services/config-service.js +264 -264
  147. package/src/services/language-service.js +47 -47
  148. package/src/services/provider-service.js +35 -0
  149. package/src/services/provider-voice-catalog.js +126 -0
  150. package/src/services/tts-engine-service.js +51 -2
  151. package/src/utils/audio-duration-validator.js +53 -10
  152. package/src/utils/audio-format-validator.js +277 -277
  153. package/src/utils/dependency-checker.js +469 -469
  154. package/src/utils/file-ownership-verifier.js +358 -358
  155. package/src/utils/music-file-validator.js +285 -285
  156. package/src/utils/preview-list-prompt.js +144 -136
  157. package/src/utils/provider-validator.js +70 -0
  158. package/src/utils/secure-music-storage.js +412 -412
  159. package/templates/agentvibes-receiver.sh +235 -231
  160. package/templates/audio/welcome-music.mp3 +0 -0
@@ -1,264 +1,264 @@
1
- /**
2
- * AgentVibes Config Service
3
- * Story 6.5: Command Routing & Entry Points (isInstalled, isBmadDetected stubs)
4
- * Story 7.1: Provider & Voice Settings Group (full read/write implementation)
5
- *
6
- * Provides config hierarchy: global (~/.agentvibes/config.json)
7
- * + project-level overrides (<projectRoot>/.agentvibes/config.json).
8
- * All path operations use path.resolve() to prevent traversal.
9
- * Config writes are atomic (write .tmp → rename) with chmod 600.
10
- */
11
-
12
- import fs from 'node:fs';
13
- import os from 'node:os';
14
- import path from 'node:path';
15
-
16
- export class ConfigService {
17
- /**
18
- * @param {object} [opts]
19
- * @param {string} [opts.projectRoot] - Project root for hook/config detection. Defaults to process.cwd().
20
- * @param {string} [opts.homeDir] - User home dir for global config. Defaults to os.homedir().
21
- */
22
- constructor(opts = {}) {
23
- // path.resolve() on all roots prevents path-traversal at the source
24
- this._projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
25
- this._homeDir = path.resolve(opts.homeDir ?? os.homedir());
26
- }
27
-
28
- /** Returns the resolved project root path. */
29
- getProjectRoot() {
30
- return this._projectRoot;
31
- }
32
-
33
- // ---------------------------------------------------------------------------
34
- // Detection (story 6.5)
35
-
36
- /**
37
- * Returns true if AgentVibes hooks are installed in this project.
38
- * Detection: .claude/hooks/play-tts.sh exists in projectRoot.
39
- */
40
- isInstalled() {
41
- const hooksFile = path.resolve(this._projectRoot, '.claude', 'hooks', 'play-tts.sh');
42
- return fs.existsSync(hooksFile);
43
- }
44
-
45
- /**
46
- * Returns true if BMAD framework is detected.
47
- * Checks: _bmad/ or .bmad/ in projectRoot, OR global voice map.
48
- */
49
- isBmadDetected() {
50
- const checks = [
51
- path.resolve(this._projectRoot, '_bmad'),
52
- path.resolve(this._projectRoot, '.bmad'),
53
- path.resolve(this._homeDir, '.agentvibes', 'bmad-voice-map.json'),
54
- ];
55
- return checks.some(p => fs.existsSync(p));
56
- }
57
-
58
- // ---------------------------------------------------------------------------
59
- // Read (story 7.1)
60
-
61
- /**
62
- * Returns global config (~/.agentvibes/config.json).
63
- * Returns {} if file does not exist.
64
- */
65
- getGlobalConfig() {
66
- const filePath = path.resolve(this._homeDir, '.agentvibes', 'config.json');
67
- return this._readConfigFile(filePath) ?? {};
68
- }
69
-
70
- /**
71
- * Returns project config (<projectRoot>/.agentvibes/config.json) or null if not present.
72
- */
73
- getProjectConfig() {
74
- const dir = path.resolve(this._projectRoot, '.agentvibes');
75
- if (!fs.existsSync(dir)) return null;
76
- const filePath = path.resolve(dir, 'config.json');
77
- return this._readConfigFile(filePath);
78
- }
79
-
80
- /**
81
- * Returns merged config: global + project overrides.
82
- * Project config keys win on conflict (deep merge for nested objects).
83
- */
84
- getConfig() {
85
- const global = this.getGlobalConfig();
86
- const project = this.getProjectConfig();
87
- return this._deepMerge(global, project);
88
- }
89
-
90
- /**
91
- * Returns config schema version from merged config.
92
- */
93
- getVersion() {
94
- return this.getConfig().version ?? '1.0';
95
- }
96
-
97
- // ---------------------------------------------------------------------------
98
- // Path helpers
99
-
100
- /** Returns the resolved global config file path. */
101
- getGlobalConfigPath() {
102
- return path.resolve(this._homeDir, '.agentvibes', 'config.json');
103
- }
104
-
105
- /** Returns the resolved local project config file path (regardless of whether it exists). */
106
- getLocalConfigPath() {
107
- return path.resolve(this._projectRoot, '.agentvibes', 'config.json');
108
- }
109
-
110
- /** Returns true if a local project config file exists. */
111
- hasLocalConfig() {
112
- return fs.existsSync(this.getLocalConfigPath());
113
- }
114
-
115
- // ---------------------------------------------------------------------------
116
- // Bulk save
117
-
118
- /**
119
- * Overwrites the ENTIRE global config with the given object.
120
- * Atomic write (tmp → rename). Creates dir if needed. chmod 600.
121
- * @param {object} data
122
- */
123
- saveAllToGlobal(data) {
124
- this._writeConfigAtomic(this.getGlobalConfigPath(), data);
125
- this._syncToTextFiles(path.resolve(this._homeDir, '.claude'), data);
126
- }
127
-
128
- /**
129
- * Overwrites the ENTIRE local project config with the given object.
130
- * Atomic write (tmp → rename). Creates dir if needed. chmod 600.
131
- * @param {object} data
132
- */
133
- saveAllToLocal(data) {
134
- this._writeConfigAtomic(this.getLocalConfigPath(), data);
135
- this._syncToTextFiles(path.resolve(this._projectRoot, '.claude'), data);
136
- }
137
-
138
- // ---------------------------------------------------------------------------
139
- // Write (story 7.1)
140
-
141
- /**
142
- * Writes a key to project config if <projectRoot>/.agentvibes/ exists,
143
- * otherwise writes to global config. Atomic write (tmp → rename). chmod 600.
144
- * @param {string} key
145
- * @param {*} value
146
- */
147
- set(key, value) {
148
- const projDir = path.resolve(this._projectRoot, '.agentvibes');
149
- if (fs.existsSync(projDir)) {
150
- const filePath = path.resolve(projDir, 'config.json');
151
- const current = this._readConfigFile(filePath) ?? {};
152
- current[key] = value;
153
- this._writeConfigAtomic(filePath, current);
154
- } else {
155
- this.setGlobal(key, value);
156
- }
157
- }
158
-
159
- /**
160
- * Always writes a key to global config (~/.agentvibes/config.json).
161
- * Atomic write (tmp → rename). chmod 600. Creates dir if needed.
162
- * @param {string} key
163
- * @param {*} value
164
- */
165
- setGlobal(key, value) {
166
- const filePath = path.resolve(this._homeDir, '.agentvibes', 'config.json');
167
- const current = this._readConfigFile(filePath) ?? {};
168
- current[key] = value;
169
- this._writeConfigAtomic(filePath, current);
170
- }
171
-
172
- // ---------------------------------------------------------------------------
173
- // Private helpers
174
-
175
- /**
176
- * Sync config.json values to .claude/ text files that TTS scripts read.
177
- * Only writes files that the config has values for. Silently ignores errors.
178
- * @param {string} claudeDir - Path to .claude/ directory
179
- * @param {object} data - Config data object
180
- */
181
- _syncToTextFiles(claudeDir, data) {
182
- if (!claudeDir || !data) return;
183
- try {
184
- if (!fs.existsSync(claudeDir)) return;
185
- if (data.voice) {
186
- fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), String(data.voice));
187
- }
188
- if (data.provider) {
189
- fs.writeFileSync(path.join(claudeDir, 'tts-provider.txt'), String(data.provider));
190
- }
191
- if (data.verbosity) {
192
- fs.writeFileSync(path.join(claudeDir, 'tts-verbosity.txt'), String(data.verbosity));
193
- }
194
- } catch { /* best-effort sync */ }
195
- }
196
-
197
- /**
198
- * Read and parse a JSON config file.
199
- * Returns null if the file does not exist or is not a regular file.
200
- * @param {string} filePath
201
- * @returns {object|null}
202
- */
203
- _readConfigFile(filePath) {
204
- if (!fs.existsSync(filePath)) return null;
205
- try {
206
- const raw = fs.readFileSync(filePath, 'utf8');
207
- return JSON.parse(raw);
208
- } catch {
209
- // Corrupt or unreadable — treat as missing
210
- return null;
211
- }
212
- }
213
-
214
- /**
215
- * Deep-merge two plain objects. Values from `override` win on conflict.
216
- * Arrays are replaced (not merged). null override → returns copy of base.
217
- * @param {object} base
218
- * @param {object|null} override
219
- * @returns {object}
220
- */
221
- _deepMerge(base, override) {
222
- if (!override || typeof override !== 'object') return { ...base };
223
- const result = { ...base };
224
- for (const [k, v] of Object.entries(override)) {
225
- if (
226
- v !== null &&
227
- typeof v === 'object' &&
228
- !Array.isArray(v) &&
229
- typeof result[k] === 'object' &&
230
- result[k] !== null &&
231
- !Array.isArray(result[k])
232
- ) {
233
- result[k] = this._deepMerge(result[k], v);
234
- } else {
235
- result[k] = v;
236
- }
237
- }
238
- return result;
239
- }
240
-
241
- /**
242
- * Atomically write data as JSON to filePath.
243
- * Writes to {filePath}.tmp then renames — safe against partial reads.
244
- * Creates parent directory if needed. Sets file permissions to 0o600.
245
- * @param {string} filePath
246
- * @param {object} data
247
- */
248
- _writeConfigAtomic(filePath, data) {
249
- const dir = path.dirname(filePath);
250
- fs.mkdirSync(dir, { recursive: true });
251
-
252
- const tmpPath = `${filePath}.tmp`;
253
- const json = JSON.stringify(data, null, 2);
254
-
255
- // Write to tmp, then atomic rename
256
- fs.writeFileSync(tmpPath, json, { encoding: 'utf8', mode: 0o600 });
257
- fs.renameSync(tmpPath, filePath);
258
-
259
- // Ensure correct permissions on final file
260
- fs.chmodSync(filePath, 0o600);
261
- }
262
- }
263
-
264
- export default ConfigService;
1
+ /**
2
+ * AgentVibes Config Service
3
+ * Story 6.5: Command Routing & Entry Points (isInstalled, isBmadDetected stubs)
4
+ * Story 7.1: Provider & Voice Settings Group (full read/write implementation)
5
+ *
6
+ * Provides config hierarchy: global (~/.agentvibes/config.json)
7
+ * + project-level overrides (<projectRoot>/.agentvibes/config.json).
8
+ * All path operations use path.resolve() to prevent traversal.
9
+ * Config writes are atomic (write .tmp → rename) with chmod 600.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ export class ConfigService {
17
+ /**
18
+ * @param {object} [opts]
19
+ * @param {string} [opts.projectRoot] - Project root for hook/config detection. Defaults to process.cwd().
20
+ * @param {string} [opts.homeDir] - User home dir for global config. Defaults to os.homedir().
21
+ */
22
+ constructor(opts = {}) {
23
+ // path.resolve() on all roots prevents path-traversal at the source
24
+ this._projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
25
+ this._homeDir = path.resolve(opts.homeDir ?? os.homedir());
26
+ }
27
+
28
+ /** Returns the resolved project root path. */
29
+ getProjectRoot() {
30
+ return this._projectRoot;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Detection (story 6.5)
35
+
36
+ /**
37
+ * Returns true if AgentVibes hooks are installed in this project.
38
+ * Detection: .claude/hooks/play-tts.sh exists in projectRoot.
39
+ */
40
+ isInstalled() {
41
+ const hooksFile = path.resolve(this._projectRoot, '.claude', 'hooks', 'play-tts.sh');
42
+ return fs.existsSync(hooksFile);
43
+ }
44
+
45
+ /**
46
+ * Returns true if BMAD framework is detected.
47
+ * Checks: _bmad/ or .bmad/ in projectRoot, OR global voice map.
48
+ */
49
+ isBmadDetected() {
50
+ const checks = [
51
+ path.resolve(this._projectRoot, '_bmad'),
52
+ path.resolve(this._projectRoot, '.bmad'),
53
+ path.resolve(this._homeDir, '.agentvibes', 'bmad-voice-map.json'),
54
+ ];
55
+ return checks.some(p => fs.existsSync(p));
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Read (story 7.1)
60
+
61
+ /**
62
+ * Returns global config (~/.agentvibes/config.json).
63
+ * Returns {} if file does not exist.
64
+ */
65
+ getGlobalConfig() {
66
+ const filePath = path.resolve(this._homeDir, '.agentvibes', 'config.json');
67
+ return this._readConfigFile(filePath) ?? {};
68
+ }
69
+
70
+ /**
71
+ * Returns project config (<projectRoot>/.agentvibes/config.json) or null if not present.
72
+ */
73
+ getProjectConfig() {
74
+ const dir = path.resolve(this._projectRoot, '.agentvibes');
75
+ if (!fs.existsSync(dir)) return null;
76
+ const filePath = path.resolve(dir, 'config.json');
77
+ return this._readConfigFile(filePath);
78
+ }
79
+
80
+ /**
81
+ * Returns merged config: global + project overrides.
82
+ * Project config keys win on conflict (deep merge for nested objects).
83
+ */
84
+ getConfig() {
85
+ const global = this.getGlobalConfig();
86
+ const project = this.getProjectConfig();
87
+ return this._deepMerge(global, project);
88
+ }
89
+
90
+ /**
91
+ * Returns config schema version from merged config.
92
+ */
93
+ getVersion() {
94
+ return this.getConfig().version ?? '1.0';
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Path helpers
99
+
100
+ /** Returns the resolved global config file path. */
101
+ getGlobalConfigPath() {
102
+ return path.resolve(this._homeDir, '.agentvibes', 'config.json');
103
+ }
104
+
105
+ /** Returns the resolved local project config file path (regardless of whether it exists). */
106
+ getLocalConfigPath() {
107
+ return path.resolve(this._projectRoot, '.agentvibes', 'config.json');
108
+ }
109
+
110
+ /** Returns true if a local project config file exists. */
111
+ hasLocalConfig() {
112
+ return fs.existsSync(this.getLocalConfigPath());
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Bulk save
117
+
118
+ /**
119
+ * Overwrites the ENTIRE global config with the given object.
120
+ * Atomic write (tmp → rename). Creates dir if needed. chmod 600.
121
+ * @param {object} data
122
+ */
123
+ saveAllToGlobal(data) {
124
+ this._writeConfigAtomic(this.getGlobalConfigPath(), data);
125
+ this._syncToTextFiles(path.resolve(this._homeDir, '.claude'), data);
126
+ }
127
+
128
+ /**
129
+ * Overwrites the ENTIRE local project config with the given object.
130
+ * Atomic write (tmp → rename). Creates dir if needed. chmod 600.
131
+ * @param {object} data
132
+ */
133
+ saveAllToLocal(data) {
134
+ this._writeConfigAtomic(this.getLocalConfigPath(), data);
135
+ this._syncToTextFiles(path.resolve(this._projectRoot, '.claude'), data);
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Write (story 7.1)
140
+
141
+ /**
142
+ * Writes a key to project config if <projectRoot>/.agentvibes/ exists,
143
+ * otherwise writes to global config. Atomic write (tmp → rename). chmod 600.
144
+ * @param {string} key
145
+ * @param {*} value
146
+ */
147
+ set(key, value) {
148
+ const projDir = path.resolve(this._projectRoot, '.agentvibes');
149
+ if (fs.existsSync(projDir)) {
150
+ const filePath = path.resolve(projDir, 'config.json');
151
+ const current = this._readConfigFile(filePath) ?? {};
152
+ current[key] = value;
153
+ this._writeConfigAtomic(filePath, current);
154
+ } else {
155
+ this.setGlobal(key, value);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Always writes a key to global config (~/.agentvibes/config.json).
161
+ * Atomic write (tmp → rename). chmod 600. Creates dir if needed.
162
+ * @param {string} key
163
+ * @param {*} value
164
+ */
165
+ setGlobal(key, value) {
166
+ const filePath = path.resolve(this._homeDir, '.agentvibes', 'config.json');
167
+ const current = this._readConfigFile(filePath) ?? {};
168
+ current[key] = value;
169
+ this._writeConfigAtomic(filePath, current);
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Private helpers
174
+
175
+ /**
176
+ * Sync config.json values to .claude/ text files that TTS scripts read.
177
+ * Only writes files that the config has values for. Silently ignores errors.
178
+ * @param {string} claudeDir - Path to .claude/ directory
179
+ * @param {object} data - Config data object
180
+ */
181
+ _syncToTextFiles(claudeDir, data) {
182
+ if (!claudeDir || !data) return;
183
+ try {
184
+ if (!fs.existsSync(claudeDir)) return;
185
+ if (data.voice) {
186
+ fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), String(data.voice));
187
+ }
188
+ if (data.provider) {
189
+ fs.writeFileSync(path.join(claudeDir, 'tts-provider.txt'), String(data.provider));
190
+ }
191
+ if (data.verbosity) {
192
+ fs.writeFileSync(path.join(claudeDir, 'tts-verbosity.txt'), String(data.verbosity));
193
+ }
194
+ } catch { /* best-effort sync */ }
195
+ }
196
+
197
+ /**
198
+ * Read and parse a JSON config file.
199
+ * Returns null if the file does not exist or is not a regular file.
200
+ * @param {string} filePath
201
+ * @returns {object|null}
202
+ */
203
+ _readConfigFile(filePath) {
204
+ if (!fs.existsSync(filePath)) return null;
205
+ try {
206
+ const raw = fs.readFileSync(filePath, 'utf8');
207
+ return JSON.parse(raw);
208
+ } catch {
209
+ // Corrupt or unreadable — treat as missing
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Deep-merge two plain objects. Values from `override` win on conflict.
216
+ * Arrays are replaced (not merged). null override → returns copy of base.
217
+ * @param {object} base
218
+ * @param {object|null} override
219
+ * @returns {object}
220
+ */
221
+ _deepMerge(base, override) {
222
+ if (!override || typeof override !== 'object') return { ...base };
223
+ const result = { ...base };
224
+ for (const [k, v] of Object.entries(override)) {
225
+ if (
226
+ v !== null &&
227
+ typeof v === 'object' &&
228
+ !Array.isArray(v) &&
229
+ typeof result[k] === 'object' &&
230
+ result[k] !== null &&
231
+ !Array.isArray(result[k])
232
+ ) {
233
+ result[k] = this._deepMerge(result[k], v);
234
+ } else {
235
+ result[k] = v;
236
+ }
237
+ }
238
+ return result;
239
+ }
240
+
241
+ /**
242
+ * Atomically write data as JSON to filePath.
243
+ * Writes to {filePath}.tmp then renames — safe against partial reads.
244
+ * Creates parent directory if needed. Sets file permissions to 0o600.
245
+ * @param {string} filePath
246
+ * @param {object} data
247
+ */
248
+ _writeConfigAtomic(filePath, data) {
249
+ const dir = path.dirname(filePath);
250
+ fs.mkdirSync(dir, { recursive: true });
251
+
252
+ const tmpPath = `${filePath}.tmp`;
253
+ const json = JSON.stringify(data, null, 2);
254
+
255
+ // Write to tmp, then atomic rename
256
+ fs.writeFileSync(tmpPath, json, { encoding: 'utf8', mode: 0o600 });
257
+ fs.renameSync(tmpPath, filePath);
258
+
259
+ // Ensure correct permissions on final file
260
+ fs.chmodSync(filePath, 0o600);
261
+ }
262
+ }
263
+
264
+ export default ConfigService;
@@ -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
+ }
@@ -77,6 +77,8 @@ export class ProviderService {
77
77
  const providers = [];
78
78
 
79
79
  if (this._isAvailable('piper')) providers.push('piper');
80
+ if (this._isKokoroInstalled()) providers.push('kokoro');
81
+ if (this._isElevenLabsConfigured()) providers.push('elevenlabs');
80
82
  if (this._isAvailable('soprano')) providers.push('soprano');
81
83
 
82
84
  // macOS Say (darwin only)
@@ -138,6 +140,39 @@ export class ProviderService {
138
140
  return false;
139
141
  }
140
142
  }
143
+
144
+ /**
145
+ * Check if the kokoro Python package is importable.
146
+ * Uses importlib.util.find_spec (no torch import) to avoid the slow load
147
+ * that can exceed the timeout and falsely report not-installed. Tries each
148
+ * platform-appropriate python command until one succeeds.
149
+ */
150
+ _isKokoroInstalled() {
151
+ // On Windows `python3` usually doesn't exist; `py` is the standard launcher.
152
+ const pythonCommands = process.platform === 'win32'
153
+ ? ['py', 'python', 'python3']
154
+ : ['python3', 'python'];
155
+ for (const pythonCmd of pythonCommands) {
156
+ try {
157
+ execFileSync(pythonCmd, ['-c', "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('kokoro') else 1)"], { stdio: 'ignore', timeout: 5000 }); // NOSONAR
158
+ return true;
159
+ } catch {
160
+ // try next python command
161
+ }
162
+ }
163
+ return false;
164
+ }
165
+
166
+ /** Check if ElevenLabs API key is configured (env var or key file). */
167
+ _isElevenLabsConfigured() {
168
+ if (process.env.ELEVENLABS_API_KEY?.trim()) return true;
169
+ try {
170
+ const keyFile = path.join(os.homedir(), '.agentvibes', 'elevenlabs-key.txt');
171
+ return fs.existsSync(keyFile) && fs.readFileSync(keyFile, 'utf8').trim().length > 0;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
141
176
  }
142
177
 
143
178
  export default ProviderService;