agentvibes 5.13.0 → 5.14.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 (82) hide show
  1. package/.claude/audio/ui/CREDITS.txt +16 -16
  2. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  3. package/.claude/commands/agent-vibes-rdp.md +24 -24
  4. package/.claude/config/audio-effects.cfg.sample +52 -52
  5. package/.claude/docs/TERMUX_SETUP.md +408 -408
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/kokoro-server.py +219 -219
  8. package/.claude/hooks/kokoro-tts.py +141 -141
  9. package/.claude/hooks/party-stage-roster.py +328 -328
  10. package/.claude/hooks/play-tts-kokoro.sh +7 -2
  11. package/.claude/hooks/play-tts-ssh-remote.sh +38 -3
  12. package/.claude/hooks/requirements.txt +6 -6
  13. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  14. package/.claude/hooks/translator.py +237 -237
  15. package/.claude/hooks-windows/kokoro-server.py +219 -219
  16. package/.claude/hooks-windows/kokoro-tts.py +107 -107
  17. package/.claude/hooks-windows/play-tts.ps1 +17 -0
  18. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  19. package/.claude/verbosity.txt +1 -1
  20. package/.clawdbot/README.md +105 -105
  21. package/CLAUDE.md +84 -176
  22. package/README.md +27 -2
  23. package/RELEASE_NOTES.md +86 -0
  24. package/WINDOWS-SETUP.md +208 -208
  25. package/bin/agent-vibes +39 -39
  26. package/bin/mcp-server.js +121 -121
  27. package/bin/test-bmad-pr +78 -78
  28. package/mcp-server/QUICK_START.md +203 -203
  29. package/mcp-server/README.md +345 -345
  30. package/mcp-server/examples/claude_desktop_config.json +11 -11
  31. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  32. package/mcp-server/examples/custom_instructions.md +169 -169
  33. package/mcp-server/install-deps.js +12 -2
  34. package/mcp-server/server.py +2111 -2085
  35. package/mcp-server/test_mcp_correctness.py +504 -504
  36. package/mcp-server/test_windows_script_parity.py +339 -339
  37. package/package.json +7 -4
  38. package/setup-ssh-receiver.ps1 +259 -0
  39. package/setup-windows.ps1 +39 -29
  40. package/src/cli/list-personalities.js +110 -110
  41. package/src/commands/bmad-voices.js +394 -394
  42. package/src/console/brand-colors.js +13 -13
  43. package/src/console/constants/personalities.js +44 -44
  44. package/src/console/tabs/agents-tab.js +14 -4
  45. package/src/console/tabs/help-tab.js +314 -314
  46. package/src/console/tabs/readme-tab.js +272 -272
  47. package/src/console/tabs/settings-tab.js +31 -6
  48. package/src/console/tabs/setup-tab.js +154 -32
  49. package/src/console/tabs/voices-tab.js +48 -6
  50. package/src/console/widgets/destroy-list.js +25 -25
  51. package/src/console/widgets/notice.js +55 -55
  52. package/src/installer/language-screen.js +31 -31
  53. package/src/installer/music-file-input.js +304 -304
  54. package/src/installer.js +103 -43
  55. package/src/services/language-service.js +47 -47
  56. package/src/services/llm-provider-service.js +4 -1
  57. package/src/services/provider-voice-catalog.js +9 -5
  58. package/src/utils/audio-format-validator.js +277 -277
  59. package/src/utils/dependency-checker.js +469 -469
  60. package/src/utils/file-ownership-verifier.js +358 -358
  61. package/src/utils/music-file-validator.js +285 -285
  62. package/src/utils/preview-list-prompt.js +144 -144
  63. package/src/utils/secure-music-storage.js +412 -412
  64. package/templates/agentvibes-receiver.ps1 +311 -0
  65. package/templates/agentvibes-receiver.sh +297 -297
  66. package/voice-assignments.json +8244 -8244
  67. package/.agentvibes/config.json +0 -17
  68. package/.agentvibes/install-manifest.json +0 -342
  69. package/.claude/config/audio-effects.cfg +0 -7
  70. package/.claude/config/audio-effects.cfg.bak-kokoro +0 -7
  71. package/.claude/config/background-music-enabled.txt +0 -1
  72. package/.claude/config/background-music-position.txt +0 -27
  73. package/.claude/config/background-music-volume.txt +0 -1
  74. package/.claude/config/background-music.cfg +0 -1
  75. package/.claude/config/background-music.txt +0 -1
  76. package/.claude/config/language.txt +0 -1
  77. package/.claude/config/personality.txt +0 -1
  78. package/.claude/config/reverb-level.txt +0 -1
  79. package/.claude/config/tts-speech-rate.txt +0 -1
  80. package/.claude/config/tts-verbosity.txt +0 -1
  81. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
  82. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
package/src/installer.js CHANGED
@@ -2874,7 +2874,7 @@ async function collectConfiguration(options = {}) {
2874
2874
  // Navigation with page titles
2875
2875
  const navChoices = [];
2876
2876
  if (currentPage < totalPages - 1) {
2877
- const nextPageTitle = getPageTitle(currentPage + 1).replace(/[🔧🎙️🎤😎💧🔊]\s*/, ''); // Remove emoji
2877
+ const nextPageTitle = getPageTitle(currentPage + 1).replace(/(?:🔧|🎙️|🎤|😎|💧|🔊)\s*/u, ''); // Remove emoji (alternation: 🎙️ is a combined char)
2878
2878
  navChoices.push({ name: chalk.green('Next →') + chalk.gray(` (${nextPageTitle})`), value: 'next' });
2879
2879
  } else {
2880
2880
  navChoices.push({ name: chalk.cyan('✓ Continue to Installation'), value: 'continue' });
@@ -2884,7 +2884,7 @@ async function collectConfiguration(options = {}) {
2884
2884
  if (currentPage === 0) {
2885
2885
  navChoices.push({ name: chalk.magentaBright('← Back to Welcome'), value: 'back' });
2886
2886
  } else {
2887
- const prevPageTitle = getPageTitle(currentPage - 1).replace(/[🔧🎙️🎤😎💧🔊]\s*/, ''); // Remove emoji
2887
+ const prevPageTitle = getPageTitle(currentPage - 1).replace(/(?:🔧|🎙️|🎤|😎|💧|🔊)\s*/u, ''); // Remove emoji (alternation: 🎙️ is a combined char)
2888
2888
  navChoices.push({ name: chalk.magentaBright('← Previous') + chalk.gray(` (${prevPageTitle})`), value: 'prev' });
2889
2889
  }
2890
2890
 
@@ -3192,16 +3192,40 @@ function execScript(scriptPath, options = {}) {
3192
3192
  const scriptFile = parts[0];
3193
3193
  const args = parts.slice(1);
3194
3194
 
3195
- // Validate that the script file doesn't contain shell metacharacters
3196
- if (scriptFile.match(/[;&|`$(){}[\]<>'"\\]/)) {
3195
+ // Validate that the script file doesn't contain shell metacharacters.
3196
+ // NOTE: `\` and `:` are deliberately NOT in this class — the script is run via
3197
+ // execFileSync(..., { shell: false }) at the bottom of this function, so the
3198
+ // path never reaches a shell and backslashes are not a metacharacter. Rejecting
3199
+ // `\` made EVERY Windows path ("C:\Users\...") throw before the directory check
3200
+ // was even reached. The real control is the allow-list below.
3201
+ if (scriptFile.match(/[;&|`$(){}[\]<>'"]/)) {
3197
3202
  throw new Error('Invalid characters in script path');
3198
3203
  }
3199
3204
 
3200
- // Validate path is within expected directory (defense in depth)
3205
+ // Validate the path is under a directory we're willing to execute from.
3206
+ //
3207
+ // This used to allow ONLY the *package's own* .claude/hooks. But every install
3208
+ // caller passes a path under the TARGET project (targetDir/.claude/hooks/...),
3209
+ // and under npx/global installs the package dir is never the target dir — so
3210
+ // the check threw for every real user. The throw was swallowed by each caller's
3211
+ // catch and surfaced as "Piper installation failed or was cancelled" / "Voice
3212
+ // download failed", blaming the script for something we never ran. It only
3213
+ // worked in a linked dev checkout, where package === target. Callers now pass
3214
+ // the base they legitimately execute from.
3215
+ const pkgRoot = path.resolve(__dirname, '..');
3216
+ const allowedDirs = [
3217
+ path.join(pkgRoot, '.claude', 'hooks'),
3218
+ path.join(pkgRoot, 'bin'),
3219
+ ...(options.allowedDirs || []).map((d) => path.resolve(d)),
3220
+ ];
3201
3221
  const resolvedPath = path.resolve(scriptFile);
3202
- const allowedDir = path.resolve(__dirname, '..', '.claude', 'hooks');
3203
- if (!resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir) {
3204
- throw new Error('Script path outside allowed directory');
3222
+ const permitted = allowedDirs.some(
3223
+ (dir) => resolvedPath === dir || resolvedPath.startsWith(dir + path.sep)
3224
+ );
3225
+ if (!permitted) {
3226
+ throw new Error(
3227
+ `Script path outside allowed directories: ${resolvedPath}\n allowed: ${allowedDirs.join(', ')}`
3228
+ );
3205
3229
  }
3206
3230
 
3207
3231
  // Security: Validate shell and shellConfig don't contain dangerous characters
@@ -3233,8 +3257,10 @@ function execScript(scriptPath, options = {}) {
3233
3257
  // Note: This means shell aliases/functions won't be available, but that's safer
3234
3258
  // S8701: scriptFile is validated to live under .claude/hooks (above), args are
3235
3259
  // passed as an array and shell:false disables shell interpretation. Risk handled.
3260
+ // allowedDirs is ours, not execFileSync's — strip it before handing options on.
3261
+ const { allowedDirs: _ignored, ...execOptions } = options;
3236
3262
  return execFileSync(scriptFile, args, { // NOSONAR
3237
- ...options,
3263
+ ...execOptions,
3238
3264
  shell: false // Don't use shell to avoid injection risks
3239
3265
  });
3240
3266
  }
@@ -4172,33 +4198,41 @@ async function copyCodexFiles(targetDir, spinner) {
4172
4198
  const srcCodexDir = path.join(__dirname, '..', '.codex');
4173
4199
  const destCodexDir = path.join(targetDir, '.codex');
4174
4200
 
4201
+ const codexManifestPath = getProjectManifestPath(targetDir);
4202
+ const codexManifest = await loadManifest(codexManifestPath);
4203
+ const codexManifestUpdates = { ...codexManifest };
4204
+
4175
4205
  let copiedFiles = [];
4176
4206
  try {
4177
4207
  await fs.mkdir(destCodexDir, { recursive: true });
4178
4208
  await fs.mkdir(path.join(destCodexDir, 'hooks'), { recursive: true });
4179
4209
 
4180
- // Copy AGENTS.md
4181
- const agentsSrc = path.join(srcCodexDir, 'AGENTS.md');
4182
- try {
4183
- const content = await fs.readFile(agentsSrc, 'utf8');
4184
- await fs.writeFile(path.join(destCodexDir, 'AGENTS.md'), content);
4185
- copiedFiles.push('.codex/AGENTS.md');
4186
- } catch { /* source not found */ }
4187
-
4188
- // Copy hook scripts
4189
- for (const hookFile of ['init-agentvibes.sh', 'init-agentvibes.ps1']) {
4190
- const hookSrc = path.join(srcCodexDir, 'hooks', hookFile);
4210
+ // Non-Destructive Configuration Rule: a user-edited AGENTS.md or Codex hook
4211
+ // must survive re-install/update. manifestSafeCopy skips (and .user.bak's) a
4212
+ // file the user changed instead of rewriting it from the template every run.
4213
+ const codexTargets = [
4214
+ { src: path.join(srcCodexDir, 'AGENTS.md'), dest: path.join(destCodexDir, 'AGENTS.md'), label: '.codex/AGENTS.md', exec: false },
4215
+ { src: path.join(srcCodexDir, 'hooks', 'init-agentvibes.sh'), dest: path.join(destCodexDir, 'hooks', 'init-agentvibes.sh'), label: '.codex/hooks/init-agentvibes.sh', exec: true },
4216
+ { src: path.join(srcCodexDir, 'hooks', 'init-agentvibes.ps1'), dest: path.join(destCodexDir, 'hooks', 'init-agentvibes.ps1'), label: '.codex/hooks/init-agentvibes.ps1', exec: false },
4217
+ ];
4218
+
4219
+ for (const { src, dest, label, exec } of codexTargets) {
4191
4220
  try {
4192
- const content = await fs.readFile(hookSrc, 'utf8');
4193
- const destPath = path.join(destCodexDir, 'hooks', hookFile);
4194
- await fs.writeFile(destPath, content);
4195
- if (hookFile.endsWith('.sh')) {
4196
- try { await fs.chmod(destPath, 0o750); } catch { /* Windows */ }
4221
+ const result = await manifestSafeCopy(src, dest, codexManifest);
4222
+ // src missing nothing copied and nothing at dest; only such a skip has no hash.
4223
+ if (result.action === 'skipped' && !result.hash) continue;
4224
+ if (exec && result.action !== 'unchanged') {
4225
+ try { await fs.chmod(dest, 0o750); } catch { /* Windows */ }
4197
4226
  }
4198
- copiedFiles.push(`.codex/hooks/${hookFile}`);
4227
+ if (result.hash && result.action !== 'skipped') {
4228
+ codexManifestUpdates[dest] = { hash: result.hash, installedAt: new Date().toISOString() };
4229
+ }
4230
+ copiedFiles.push(label);
4199
4231
  } catch { /* source not found */ }
4200
4232
  }
4201
4233
 
4234
+ await saveManifest(codexManifestPath, codexManifestUpdates).catch(() => { /* best effort */ });
4235
+
4202
4236
  if (copiedFiles.length > 0) {
4203
4237
  spinner.succeed(chalk.green(`Installed ${copiedFiles.length} Codex file${copiedFiles.length === 1 ? '' : 's'}!\n`));
4204
4238
  copiedFiles.forEach(file => {
@@ -4341,11 +4375,23 @@ async function configurePartyModeHook(targetDir, spinner, homeDirOverride) {
4341
4375
  const srcScript = path.join(__dirname, '..', '.claude', hooksSubdir, scriptName);
4342
4376
  const destScript = path.join(globalHooksDir, scriptName);
4343
4377
 
4344
- // Copy script to global hooks dir (create dir if needed)
4378
+ // Copy script to global hooks dir (create dir if needed). Non-Destructive
4379
+ // Configuration Rule: a user-modified bmad-party-speak script must survive an
4380
+ // update, so go through manifestSafeCopy (which .user.bak's and skips a file
4381
+ // the user changed) instead of an unconditional overwrite. The sibling
4382
+ // updateGlobalHooks path for this same file is already manifest-guarded.
4345
4383
  await fs.mkdir(globalHooksDir, { recursive: true });
4346
- await fs.copyFile(srcScript, destScript);
4347
- if (!isNativeWindows()) {
4348
- await fs.chmod(destScript, 0o750);
4384
+ const scriptManifestPath = getGlobalManifestPath(homeDir);
4385
+ const scriptManifest = await loadManifest(scriptManifestPath);
4386
+ const scriptCopy = await manifestSafeCopy(srcScript, destScript, scriptManifest);
4387
+ if (scriptCopy.action !== 'skipped') {
4388
+ if (!isNativeWindows() && scriptCopy.action !== 'unchanged') {
4389
+ await fs.chmod(destScript, 0o750);
4390
+ }
4391
+ if (scriptCopy.hash) {
4392
+ scriptManifest[destScript] = { hash: scriptCopy.hash, installedAt: new Date().toISOString() };
4393
+ await saveManifest(scriptManifestPath, scriptManifest).catch(() => { /* best effort */ });
4394
+ }
4349
4395
  }
4350
4396
 
4351
4397
  // Build the PostToolUse hook command
@@ -4480,7 +4526,8 @@ async function offerLibriTTSDownload(piperDownloadPath, options) {
4480
4526
  try {
4481
4527
  execScript(`${piperDownloadPath} --libritts --yes`, {
4482
4528
  stdio: 'inherit',
4483
- env: process.env
4529
+ env: process.env,
4530
+ allowedDirs: [path.dirname(piperDownloadPath)]
4484
4531
  });
4485
4532
  console.log(chalk.green('\n✅ LibriTTS voices downloaded! Browse with /agent-vibes:list\n'));
4486
4533
  } catch {
@@ -4531,7 +4578,8 @@ async function checkAndInstallPiper(targetDir, options) {
4531
4578
  if (fsSync.existsSync(piperDownloadPath)) {
4532
4579
  execScript(`${piperDownloadPath} --yes`, {
4533
4580
  stdio: options.silent ? 'pipe' : 'inherit',
4534
- env: process.env
4581
+ env: process.env,
4582
+ allowedDirs: [path.dirname(piperDownloadPath)]
4535
4583
  });
4536
4584
  console.log(chalk.green('\n✅ Voice models downloaded successfully!\n'));
4537
4585
  } else {
@@ -4576,7 +4624,8 @@ async function checkAndInstallPiper(targetDir, options) {
4576
4624
  try {
4577
4625
  execScript(`${piperInstallerPath} --non-interactive`, {
4578
4626
  stdio: options.silent ? 'pipe' : 'inherit',
4579
- env: process.env
4627
+ env: process.env,
4628
+ allowedDirs: [path.dirname(piperInstallerPath)]
4580
4629
  });
4581
4630
  console.log(chalk.green('\n✅ Piper TTS installed successfully!\n'));
4582
4631
  } catch (error) {
@@ -5650,10 +5699,14 @@ async function performUpdateOperations(targetDir, spinner) {
5650
5699
  console.log(chalk.green(`✓ Updated ${resolverResult.count} resolver bundle files`));
5651
5700
  }
5652
5701
 
5653
- // Also update critical hooks in global ~/.claude/hooks/ if present (fixes stale installs)
5654
- const hooksSubdir = isNativeWindows() ? 'hooks-windows' : 'hooks';
5655
- const srcHooksDir = path.join(__dirname, '..', '.claude', hooksSubdir);
5656
- const globalHooksUpdated = await updateGlobalHooks(srcHooksDir);
5702
+ // Also update critical hooks in global ~/.claude/hooks/ if present (fixes stale installs).
5703
+ // ALWAYS pass the unix hooks dir: updateGlobalHooks copies CRITICAL_HOOKS (all .sh)
5704
+ // from here and derives hooks-windows itself for CRITICAL_HOOKS_WINDOWS. Passing
5705
+ // hooks-windows on Windows made every .sh lookup miss and hit the "src missing"
5706
+ // catch, so ~/.claude/hooks/*.sh — which git-bash hooks actually execute on
5707
+ // Windows — silently never updated (installs stayed frozen at their first version).
5708
+ const srcGlobalHooksDir = path.join(__dirname, '..', '.claude', 'hooks');
5709
+ const globalHooksUpdated = await updateGlobalHooks(srcGlobalHooksDir);
5657
5710
  if (globalHooksUpdated > 0) {
5658
5711
  console.log(chalk.green(`✓ Updated ${globalHooksUpdated} critical scripts in ~/.claude/hooks/`));
5659
5712
  }
@@ -5951,10 +6004,10 @@ async function install(options = {}) {
5951
6004
  await copyCodexFiles(targetDir, silentSpinner);
5952
6005
 
5953
6006
  // Populate global ~/.claude/hooks[/-windows]/ so $HOME hook paths resolve
5954
- // on first install (not just on update).
5955
- const hooksSubdirInstall = isNativeWindows() ? 'hooks-windows' : 'hooks';
5956
- const srcHooksDirInstall = path.join(__dirname, '..', '.claude', hooksSubdirInstall);
5957
- await updateGlobalHooks(srcHooksDirInstall);
6007
+ // on first install (not just on update). Always the unix hooks dir — see the
6008
+ // note in the update path; updateGlobalHooks derives hooks-windows itself.
6009
+ const srcGlobalHooksDirInstall = path.join(__dirname, '..', '.claude', 'hooks');
6010
+ await updateGlobalHooks(srcGlobalHooksDirInstall);
5958
6011
  // Populate global ~/.claude/ resolver bundle too, same reasoning as above (AVI-S8.5 Stage 2)
5959
6012
  await updateGlobalResolverBundle();
5960
6013
 
@@ -6781,8 +6834,11 @@ program
6781
6834
  .command('agentvibes-mcp-server')
6782
6835
  .description('Start AgentVibes MCP server')
6783
6836
  .action(async () => {
6784
- // Run the bash wrapper script
6785
- const mcpServerScript = path.join(__dirname, '..', 'bin', 'mcp-server');
6837
+ // Run the bash wrapper script. NOTE: 'bin/mcp-server' (no extension) has
6838
+ // never existed bin/ ships mcp-server.sh and mcp-server.js. Combined with
6839
+ // the catch below exiting silently, this subcommand failed with no output at
6840
+ // all for every user who tried it.
6841
+ const mcpServerScript = path.join(__dirname, '..', 'bin', 'mcp-server.sh');
6786
6842
 
6787
6843
  try {
6788
6844
  execScript(mcpServerScript, {
@@ -6790,6 +6846,9 @@ program
6790
6846
  env: process.env
6791
6847
  });
6792
6848
  } catch (error) {
6849
+ // Never exit silently — say why.
6850
+ console.error(chalk.red(`\n❌ Could not start the MCP server: ${error.message}`));
6851
+ console.error(chalk.gray(` Script: ${mcpServerScript}`));
6793
6852
  process.exit(error.status || 1);
6794
6853
  }
6795
6854
  });
@@ -7112,6 +7171,7 @@ export {
7112
7171
  // Manifest utilities (used by tests and external tooling)
7113
7172
  getProjectManifestPath, getGlobalManifestPath,
7114
7173
  loadManifest, saveManifest, computeFileHash, manifestSafeCopy, backupUserFile, removeManifestFiles,
7174
+ execScript,
7115
7175
  // Pure helper functions exported for testing
7116
7176
  readJsonConfigSafe, backupConfigFile,
7117
7177
  isPiperProvider, supportsEmoji, getPersonalityIcon,
@@ -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
+ }
@@ -964,7 +964,10 @@ export function saveLlmConfigSync(llmKey, config, targetDir) {
964
964
  const cfgKey = `llm:${llmKey}`;
965
965
  // Sanitize user-editable fields: strip pipe chars (config delimiter) and newlines
966
966
  // (newlines could inject extra rows into the pipe-delimited config file)
967
- const sanitize = (v) => (v || '').replace(/[\|\n\r\x00]/g, '');
967
+ // Strip the pipe delimiter and any control character (newlines/NUL could inject
968
+ // extra rows). Done via a char filter rather than control chars in a regex
969
+ // literal (Sonar S6324 flags regex control chars).
970
+ const sanitize = (v) => Array.from(String(v || '')).filter(ch => ch !== '|' && ch.charCodeAt(0) >= 0x20).join('');
968
971
  const cfgLine = `${cfgKey}|${sanitize(config.effects)}|${sanitize(config.bgTrack)}|${sanitize(config.bgVolume)}|${sanitize(config.voice)}|${sanitize(config.pretext)}|${sanitize(config.ttsEngine)}`;
969
972
  const resolvedTargetDir = targetDir || process.env.INIT_CWD || process.cwd();
970
973
  // When targetDir is explicitly passed, always write to the project dir — never follow
@@ -33,12 +33,16 @@ export { ELEVENLABS_VOICES, KOKORO_VOICE_IDS, kokoroGender };
33
33
  * Verified against a real Windows 11 install (System.Speech GetInstalledVoices).
34
34
  * The receiver remains authoritative — this is UI convenience, not inventory SSOT.
35
35
  */
36
+ // Only the classic SAPI5 "Desktop" voices are listed. The modern OneCore voices
37
+ // ("Microsoft David/Mark/Zira" WITHOUT "Desktop") are NOT selectable via
38
+ // System.Speech in a spawned process — the receiver's player is spawned, so
39
+ // SelectVoice throws "No matching voice is installed" and silently falls back to
40
+ // the default (David, male). That made e.g. "Microsoft Zira" play as a MALE voice.
41
+ // David Desktop + Zira Desktop are present on every Windows 10/11 and select
42
+ // reliably in any process/bitness, so they are the safe curated set.
36
43
  const WINDOWS_SAPI_VOICES = [
37
- { id: 'Microsoft David Desktop', name: 'David (Desktop)', gender: 'Male', lang: 'en-US' },
38
- { id: 'Microsoft Zira Desktop', name: 'Zira (Desktop)', gender: 'Female', lang: 'en-US' },
39
- { id: 'Microsoft David', name: 'David', gender: 'Male', lang: 'en-US' },
40
- { id: 'Microsoft Mark', name: 'Mark', gender: 'Male', lang: 'en-US' },
41
- { id: 'Microsoft Zira', name: 'Zira', gender: 'Female', lang: 'en-US' },
44
+ { id: 'Microsoft David Desktop', name: 'David', gender: 'Male', lang: 'en-US' },
45
+ { id: 'Microsoft Zira Desktop', name: 'Zira', gender: 'Female', lang: 'en-US' },
42
46
  ];
43
47
 
44
48
  const MACOS_VOICES = [