agentvibes 5.13.1 → 5.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/audio/ui/CREDITS.txt +16 -16
- package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
- package/.claude/commands/agent-vibes-rdp.md +24 -24
- package/.claude/config/audio-effects.cfg.sample +52 -52
- package/.claude/docs/TERMUX_SETUP.md +408 -408
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/kokoro-server.py +219 -219
- package/.claude/hooks/kokoro-tts.py +141 -141
- package/.claude/hooks/party-stage-roster.py +328 -328
- package/.claude/hooks/play-tts-kokoro.sh +7 -2
- package/.claude/hooks/play-tts-ssh-remote.sh +38 -3
- package/.claude/hooks/requirements.txt +6 -6
- package/.claude/hooks/soprano-gradio-synth.py +139 -139
- package/.claude/hooks/translator.py +237 -237
- package/.claude/hooks-windows/agentvibes-session-id.ps1 +83 -0
- package/.claude/hooks-windows/kokoro-server.py +219 -219
- package/.claude/hooks-windows/kokoro-tts.py +107 -107
- package/.claude/hooks-windows/play-tts.ps1 +112 -1
- package/.claude/hooks-windows/session-start-tts.ps1 +36 -0
- package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
- package/.claude/verbosity.txt +1 -1
- package/.clawdbot/README.md +105 -105
- package/CLAUDE.md +84 -176
- package/README.md +35 -2
- package/RELEASE_NOTES.md +139 -0
- package/WINDOWS-SETUP.md +208 -208
- package/bin/agent-vibes +39 -39
- package/bin/mcp-server.js +121 -121
- package/bin/test-bmad-pr +78 -78
- package/mcp-server/QUICK_START.md +203 -203
- package/mcp-server/README.md +345 -345
- package/mcp-server/examples/claude_desktop_config.json +11 -11
- package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
- package/mcp-server/examples/custom_instructions.md +169 -169
- package/mcp-server/install-deps.js +12 -2
- package/mcp-server/server.py +2111 -2085
- package/mcp-server/test_mcp_correctness.py +504 -504
- package/mcp-server/test_windows_script_parity.py +339 -339
- package/package.json +6 -3
- package/setup-ssh-receiver.ps1 +259 -0
- package/setup-windows.ps1 +39 -29
- package/src/cli/list-personalities.js +110 -110
- package/src/commands/bmad-voices.js +394 -394
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/tabs/agents-tab.js +14 -4
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/settings-tab.js +31 -6
- package/src/console/tabs/setup-tab.js +154 -32
- package/src/console/tabs/voices-tab.js +48 -6
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +98 -37
- package/src/services/language-service.js +47 -47
- package/src/services/llm-provider-service.js +4 -1
- package/src/services/provider-voice-catalog.js +9 -5
- 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/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +144 -144
- package/src/utils/secure-music-storage.js +412 -412
- package/templates/agentvibes-receiver.ps1 +311 -0
- package/templates/agentvibes-receiver.sh +297 -297
- package/voice-assignments.json +8244 -8244
- package/.agentvibes/config.json +0 -17
- package/.agentvibes/install-manifest.json +0 -362
- package/.claude/config/audio-effects.cfg +0 -7
- package/.claude/config/audio-effects.cfg.bak-kokoro +0 -7
- package/.claude/config/background-music-enabled.txt +0 -1
- package/.claude/config/background-music-position.txt +0 -27
- package/.claude/config/background-music-volume.txt +0 -1
- package/.claude/config/background-music.cfg +0 -1
- package/.claude/config/background-music.txt +0 -1
- package/.claude/config/language.txt +0 -1
- package/.claude/config/personality.txt +0 -1
- package/.claude/config/reverb-level.txt +0 -1
- package/.claude/config/tts-speech-rate.txt +0 -1
- package/.claude/config/tts-verbosity.txt +0 -1
- package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
- 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(/
|
|
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(/
|
|
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
|
-
|
|
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
|
|
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
|
|
3203
|
-
|
|
3204
|
-
|
|
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
|
-
...
|
|
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
|
-
//
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
for (const
|
|
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
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
if (
|
|
4196
|
-
try { await fs.chmod(
|
|
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 */ }
|
|
4226
|
+
}
|
|
4227
|
+
if (result.hash && result.action !== 'skipped') {
|
|
4228
|
+
codexManifestUpdates[dest] = { hash: result.hash, installedAt: new Date().toISOString() };
|
|
4197
4229
|
}
|
|
4198
|
-
copiedFiles.push(
|
|
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
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
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) {
|
|
@@ -5393,8 +5442,13 @@ async function updateCommandFiles(targetDir, spinner) {
|
|
|
5393
5442
|
* These hooks contain bug fixes (e.g. markdown stripping) that must propagate
|
|
5394
5443
|
* on every `npx agentvibes update` regardless of target directory.
|
|
5395
5444
|
*/
|
|
5396
|
-
|
|
5397
|
-
|
|
5445
|
+
// agentvibes-session-id.{sh,ps1} are listed because play-tts.{sh,ps1} SHELL OUT to
|
|
5446
|
+
// them to expand a {{session}} pretext. They were absent here, so on a global
|
|
5447
|
+
// install the global-update path never shipped them and the self-ID silently
|
|
5448
|
+
// degraded to an empty string forever — the callers fail soft by design, so the
|
|
5449
|
+
// omission produced no error, just a feature that never worked.
|
|
5450
|
+
const CRITICAL_HOOKS = ['stop-tts.sh', 'stop.sh', 'play-tts.sh', 'play-tts-piper.sh', 'audio-processor.sh', 'session-start-tts.sh', 'bmad-party-speak.sh', 'agentvibes-session-id.sh'];
|
|
5451
|
+
const CRITICAL_HOOKS_WINDOWS = ['play-tts.ps1', 'play-tts-piper.ps1', 'audio-processor.ps1', 'session-start-tts.ps1', 'bmad-speak.ps1', 'bmad-party-speak.ps1', 'tts-watcher.ps1', 'agentvibes-session-id.ps1'];
|
|
5398
5452
|
|
|
5399
5453
|
/**
|
|
5400
5454
|
* Update critical hooks in the global ~/.claude/hooks/ directory if it exists.
|
|
@@ -6785,8 +6839,11 @@ program
|
|
|
6785
6839
|
.command('agentvibes-mcp-server')
|
|
6786
6840
|
.description('Start AgentVibes MCP server')
|
|
6787
6841
|
.action(async () => {
|
|
6788
|
-
// Run the bash wrapper script
|
|
6789
|
-
|
|
6842
|
+
// Run the bash wrapper script. NOTE: 'bin/mcp-server' (no extension) has
|
|
6843
|
+
// never existed — bin/ ships mcp-server.sh and mcp-server.js. Combined with
|
|
6844
|
+
// the catch below exiting silently, this subcommand failed with no output at
|
|
6845
|
+
// all for every user who tried it.
|
|
6846
|
+
const mcpServerScript = path.join(__dirname, '..', 'bin', 'mcp-server.sh');
|
|
6790
6847
|
|
|
6791
6848
|
try {
|
|
6792
6849
|
execScript(mcpServerScript, {
|
|
@@ -6794,6 +6851,9 @@ program
|
|
|
6794
6851
|
env: process.env
|
|
6795
6852
|
});
|
|
6796
6853
|
} catch (error) {
|
|
6854
|
+
// Never exit silently — say why.
|
|
6855
|
+
console.error(chalk.red(`\n❌ Could not start the MCP server: ${error.message}`));
|
|
6856
|
+
console.error(chalk.gray(` Script: ${mcpServerScript}`));
|
|
6797
6857
|
process.exit(error.status || 1);
|
|
6798
6858
|
}
|
|
6799
6859
|
});
|
|
@@ -7116,6 +7176,7 @@ export {
|
|
|
7116
7176
|
// Manifest utilities (used by tests and external tooling)
|
|
7117
7177
|
getProjectManifestPath, getGlobalManifestPath,
|
|
7118
7178
|
loadManifest, saveManifest, computeFileHash, manifestSafeCopy, backupUserFile, removeManifestFiles,
|
|
7179
|
+
execScript,
|
|
7119
7180
|
// Pure helper functions exported for testing
|
|
7120
7181
|
readJsonConfigSafe, backupConfigFile,
|
|
7121
7182
|
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
|
-
|
|
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
|
|
38
|
-
{ id: 'Microsoft Zira Desktop', name: 'Zira
|
|
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 = [
|