agentvibes 5.6.9 → 5.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.agentvibes/config.json +3 -38
  2. package/.claude/commands/agent-vibes/provider.md +0 -0
  3. package/.claude/config/audio-effects.cfg +1 -1
  4. package/.claude/config/background-music-position.txt +6 -8
  5. package/.claude/config/reverb-level.txt +0 -0
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/bmad-tts-injector.sh +49 -21
  8. package/.claude/hooks/migrate-to-agentvibes.sh +24 -16
  9. package/.claude/hooks/personality-manager.sh +15 -2
  10. package/.claude/hooks/play-tts.sh +6 -0
  11. package/.claude/hooks/provider-commands.sh +16 -4
  12. package/.claude/hooks/provider-manager.sh +38 -0
  13. package/.claude/hooks/stop.sh +2 -27
  14. package/.claude/hooks/voice-manager.sh +50 -2
  15. package/.claude/hooks-windows/play-tts.ps1 +34 -1
  16. package/.claude/hooks-windows/tts-watcher.ps1 +122 -0
  17. package/.claude/piper-voices-dir.txt +1 -1
  18. package/.mcp.json +13 -33
  19. package/README.md +6 -8
  20. package/RELEASE_NOTES.md +32 -0
  21. package/bin/agent-vibes +39 -39
  22. package/package.json +1 -1
  23. package/src/bmad-detector.js +85 -71
  24. package/src/cli/list-personalities.js +110 -110
  25. package/src/cli/list-voices.js +114 -114
  26. package/src/commands/bmad-voices.js +394 -394
  27. package/src/commands/install-mcp.js +476 -476
  28. package/src/console/brand-colors.js +13 -13
  29. package/src/console/constants/personalities.js +44 -44
  30. package/src/console/tabs/help-tab.js +314 -314
  31. package/src/console/tabs/readme-tab.js +272 -272
  32. package/src/console/widgets/destroy-list.js +25 -25
  33. package/src/console/widgets/notice.js +55 -55
  34. package/src/console/widgets/personality-picker.js +213 -213
  35. package/src/i18n/de.js +202 -202
  36. package/src/i18n/es.js +202 -202
  37. package/src/i18n/fr.js +202 -202
  38. package/src/i18n/hi.js +202 -202
  39. package/src/i18n/ja.js +202 -202
  40. package/src/i18n/ko.js +202 -202
  41. package/src/i18n/pt.js +202 -202
  42. package/src/i18n/strings.js +54 -54
  43. package/src/i18n/zh-CN.js +202 -202
  44. package/src/installer/language-screen.js +31 -31
  45. package/src/installer/music-file-input.js +304 -304
  46. package/src/installer.js +330 -64
  47. package/src/services/agent-voice-store.js +59 -12
  48. package/src/services/config-service.js +264 -264
  49. package/src/services/language-service.js +47 -47
  50. package/src/services/llm-provider-service.js +57 -12
  51. package/src/services/provider-service.js +143 -143
  52. package/src/utils/audio-duration-validator.js +298 -298
  53. package/src/utils/audio-format-validator.js +277 -277
  54. package/src/utils/dependency-checker.js +469 -469
  55. package/src/utils/file-ownership-verifier.js +358 -358
  56. package/src/utils/list-formatter.js +194 -194
  57. package/src/utils/music-file-validator.js +285 -285
  58. package/src/utils/preview-list-prompt.js +136 -136
  59. package/src/utils/secure-music-storage.js +412 -412
  60. package/.agentvibes/LITE-MODE.md +0 -236
  61. package/.agentvibes/README.md +0 -136
  62. package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +0 -141
  63. package/.agentvibes/backups/agents/analyst_20260204_144958.md +0 -78
  64. package/.agentvibes/backups/agents/architect_20260204_144958.md +0 -72
  65. package/.agentvibes/backups/agents/dev_20260204_144958.md +0 -74
  66. package/.agentvibes/backups/agents/pm_20260204_144958.md +0 -72
  67. package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +0 -64
  68. package/.agentvibes/backups/agents/sm_20260204_144958.md +0 -87
  69. package/.agentvibes/backups/agents/tea_20260204_144958.md +0 -79
  70. package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +0 -82
  71. package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +0 -80
  72. package/.agentvibes/config/README-personality-defaults.md +0 -162
  73. package/.agentvibes/config/agentvibes.json +0 -1
  74. package/.agentvibes/config/mode.txt +0 -1
  75. package/.agentvibes/config/personality-voice-defaults.default.json +0 -21
  76. package/.agentvibes/config/save-audio.txt +0 -1
  77. package/.agentvibes/config/voice-metadata.json +0 -160
  78. package/.agentvibes/hooks/help.sh +0 -191
  79. package/.agentvibes/hooks/post-tool-use-lite.sh +0 -111
  80. package/.agentvibes/hooks/save-audio-manager.sh +0 -162
  81. package/.agentvibes/hooks/session-start-full-optimized.sh +0 -102
  82. package/.agentvibes/hooks/session-start-full.sh +0 -142
  83. package/.agentvibes/hooks/session-start-lite-v2.sh +0 -34
  84. package/.agentvibes/hooks/session-start-lite.sh +0 -29
  85. package/.agentvibes/hooks/stop-lite.sh +0 -115
  86. package/.agentvibes/hooks/switch-mode.sh +0 -215
  87. package/.agentvibes/output-styles/audio-summary.md +0 -30
  88. package/.claude/audio/voice-samples/piper/alan.wav +0 -0
  89. package/.claude/audio/voice-samples/piper/amy.wav +0 -0
  90. package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
  91. package/.claude/audio/voice-samples/piper/joe.wav +0 -0
  92. package/.claude/audio/voice-samples/piper/john.wav +0 -0
  93. package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
  94. package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
  95. package/.claude/audio/voice-samples/piper/linda.wav +0 -0
  96. package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
  97. package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
  98. package/.claude/hooks/post-response.sh +0 -41
  99. package/bin/ensure-soprano-running.sh +0 -43
package/src/installer.js CHANGED
@@ -84,6 +84,105 @@ const packageJson = JSON.parse(
84
84
  );
85
85
  const VERSION = packageJson.version;
86
86
 
87
+ // ── Install manifest utilities ──────────────────────────────────────────────
88
+ // The manifest records the SHA-256 hash of every file AgentVibes installs,
89
+ // so update() can detect user modifications before overwriting. Files whose
90
+ // hash has changed since the last install are skipped (a .user.bak copy is
91
+ // saved), preventing silent destruction of user customisations.
92
+
93
+ const MANIFEST_FILENAME = 'install-manifest.json';
94
+
95
+ function getProjectManifestPath(targetDir) {
96
+ return path.join(targetDir, '.agentvibes', MANIFEST_FILENAME);
97
+ }
98
+
99
+ function getGlobalManifestPath(homeDir) {
100
+ return path.join(homeDir || os.homedir(), '.agentvibes', MANIFEST_FILENAME);
101
+ }
102
+
103
+ async function computeFileHash(filePath) {
104
+ try {
105
+ const buf = await fs.readFile(filePath);
106
+ return crypto.createHash('sha256').update(buf).digest('hex');
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ async function loadManifest(manifestPath) {
113
+ try {
114
+ const data = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
115
+ return data.files ?? {};
116
+ } catch {
117
+ return {};
118
+ }
119
+ }
120
+
121
+ async function saveManifest(manifestPath, files) {
122
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
123
+ const tmp = manifestPath + '.tmp.' + process.pid;
124
+ try {
125
+ await fs.writeFile(
126
+ tmp,
127
+ JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), files }, null, 2),
128
+ { mode: 0o600 }
129
+ );
130
+ await fs.rename(tmp, manifestPath);
131
+ } catch (err) {
132
+ try { await fs.unlink(tmp); } catch { /* cleanup */ }
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ // Copies srcPath → destPath only when safe:
138
+ // • dest missing → copy (action: 'new')
139
+ // • dest == src (already current) → skip (action: 'unchanged')
140
+ // • dest hash == manifest hash → copy (action: 'updated')
141
+ // • dest hash != manifest hash → skip (action: 'skipped', .user.bak saved)
142
+ // • no manifest entry yet → copy (action: 'updated')
143
+ async function manifestSafeCopy(srcPath, destPath, manifest) {
144
+ const srcHash = await computeFileHash(srcPath);
145
+ if (!srcHash) return { action: 'skipped', hash: null }; // src missing
146
+
147
+ const destHash = await computeFileHash(destPath);
148
+
149
+ if (!destHash) {
150
+ await fs.copyFile(srcPath, destPath);
151
+ return { action: 'new', hash: srcHash };
152
+ }
153
+
154
+ if (destHash === srcHash) {
155
+ return { action: 'unchanged', hash: srcHash };
156
+ }
157
+
158
+ const manifestHash = manifest[destPath]?.hash;
159
+ if (manifestHash && destHash !== manifestHash) {
160
+ // User modified the file since we last installed it — preserve it
161
+ try { await fs.copyFile(destPath, destPath + '.user.bak'); } catch { /* best effort */ }
162
+ return { action: 'skipped', hash: destHash };
163
+ }
164
+
165
+ // Stock file (or no manifest entry yet) — safe to overwrite
166
+ await fs.copyFile(srcPath, destPath);
167
+ return { action: 'updated', hash: srcHash };
168
+ }
169
+
170
+ // Delete only the files listed in manifest that reside under baseDir.
171
+ // Then attempt (non-recursively) to prune any resulting empty directories.
172
+ async function removeManifestFiles(manifest, baseDir, dirsToTryPrune = []) {
173
+ const base = path.resolve(baseDir);
174
+ let count = 0;
175
+ for (const filePath of Object.keys(manifest)) {
176
+ const resolved = path.resolve(filePath);
177
+ if (!resolved.startsWith(base + path.sep)) continue;
178
+ try { await fs.unlink(filePath); count++; } catch { /* already gone */ }
179
+ }
180
+ for (const dir of dirsToTryPrune) {
181
+ try { await fs.rmdir(dir); } catch { /* not empty or already gone — leave it */ }
182
+ }
183
+ return count;
184
+ }
185
+
87
186
  // Personality emoji mapping for quick visual recognition
88
187
  const personalityEmojis = {
89
188
  'angry': '😠',
@@ -3387,19 +3486,30 @@ async function copyCommandFiles(targetDir, spinner) {
3387
3486
  let failedCommands = [];
3388
3487
  let successCount = 0;
3389
3488
 
3489
+ const manifestPath = getProjectManifestPath(targetDir);
3490
+ const manifest = await loadManifest(manifestPath);
3491
+ const manifestUpdates = {};
3492
+
3390
3493
  for (const file of commandFiles) {
3391
3494
  const srcPath = path.join(srcCommandsDir, file);
3392
3495
  const destPath = path.join(agentVibesCommandsDir, file);
3393
3496
  try {
3394
- await fs.copyFile(srcPath, destPath);
3395
- installedCommands.push(file);
3396
- successCount++;
3497
+ const result = await manifestSafeCopy(srcPath, destPath, manifest);
3498
+ if (result.action !== 'skipped') {
3499
+ installedCommands.push(file);
3500
+ successCount++;
3501
+ if (result.hash) manifestUpdates[destPath] = { hash: result.hash, installedAt: new Date().toISOString() };
3502
+ }
3397
3503
  } catch (err) {
3398
3504
  failedCommands.push({ file, error: err.message });
3399
3505
  // Continue with other files
3400
3506
  }
3401
3507
  }
3402
3508
 
3509
+ if (Object.keys(manifestUpdates).length > 0) {
3510
+ await saveManifest(manifestPath, { ...manifest, ...manifestUpdates }).catch(() => { /* best effort */ });
3511
+ }
3512
+
3403
3513
  if (successCount === commandFiles.length) {
3404
3514
  spinner.succeed(chalk.green(`Installed ${successCount} slash commands!\n`));
3405
3515
  } else {
@@ -3515,16 +3625,17 @@ async function filterHookFiles(srcHooksDir, allFiles) {
3515
3625
  * @param {string} filename - Name of the file
3516
3626
  * @returns {Promise<Object>} Result object with success/error info
3517
3627
  */
3518
- async function copyHookFile(srcPath, destPath, filename) {
3628
+ async function copyHookFile(srcPath, destPath, filename, manifest = {}) {
3519
3629
  try {
3520
- await fs.copyFile(srcPath, destPath);
3521
-
3522
- if (filename.endsWith('.sh')) {
3630
+ const result = await manifestSafeCopy(srcPath, destPath, manifest);
3631
+ if (result.action === 'skipped' && result.hash !== null) {
3632
+ // User-modified file preserved — do not chmod or report as installed
3633
+ return { success: true, name: filename, executable: false, userModified: true, hash: null };
3634
+ }
3635
+ if (filename.endsWith('.sh') && result.action !== 'skipped') {
3523
3636
  await fs.chmod(destPath, 0o750);
3524
- return { success: true, name: filename, executable: true };
3525
3637
  }
3526
-
3527
- return { success: true, name: filename, executable: false };
3638
+ return { success: true, name: filename, executable: filename.endsWith('.sh'), hash: result.hash };
3528
3639
  } catch (err) {
3529
3640
  return { success: false, name: filename, error: err.message };
3530
3641
  }
@@ -3600,21 +3711,33 @@ async function copyHookFiles(targetDir, spinner) {
3600
3711
 
3601
3712
  spinner.start(`Installing ${hookFiles.length} TTS scripts...`);
3602
3713
 
3714
+ const manifestPath = getProjectManifestPath(targetDir);
3715
+ const manifest = await loadManifest(manifestPath);
3716
+ const manifestUpdates = {};
3717
+
3603
3718
  const installedFiles = [];
3604
3719
  const failedFiles = [];
3605
3720
 
3606
3721
  for (const file of hookFiles) {
3607
3722
  const srcPath = path.join(srcHooksDir, file);
3608
3723
  const destPath = path.join(hooksDir, file);
3609
- const result = await copyHookFile(srcPath, destPath, file);
3724
+ const result = await copyHookFile(srcPath, destPath, file, manifest);
3610
3725
 
3611
3726
  if (result.success) {
3612
- installedFiles.push({ name: result.name, executable: result.executable });
3727
+ if (!result.userModified) {
3728
+ installedFiles.push({ name: result.name, executable: result.executable });
3729
+ if (result.hash) manifestUpdates[destPath] = { hash: result.hash, installedAt: new Date().toISOString() };
3730
+ }
3731
+ // userModified files are silently skipped (their .user.bak is already saved)
3613
3732
  } else {
3614
3733
  failedFiles.push({ name: result.name, error: result.error });
3615
3734
  }
3616
3735
  }
3617
3736
 
3737
+ if (Object.keys(manifestUpdates).length > 0) {
3738
+ await saveManifest(manifestPath, { ...manifest, ...manifestUpdates }).catch(() => { /* best effort */ });
3739
+ }
3740
+
3618
3741
  const successCount = installedFiles.length;
3619
3742
 
3620
3743
  if (successCount === hookFiles.length) {
@@ -3663,11 +3786,22 @@ async function copyPersonalityFiles(targetDir, spinner) {
3663
3786
  spinner.start(`Installing ${personalityMdFiles.length} personality templates...`);
3664
3787
  let installedPersonalities = [];
3665
3788
 
3789
+ const personalityManifestPath = getProjectManifestPath(targetDir);
3790
+ const personalityManifest = await loadManifest(personalityManifestPath);
3791
+ const personalityManifestUpdates = {};
3792
+
3666
3793
  for (const file of personalityMdFiles) {
3667
3794
  const srcPath = path.join(srcPersonalitiesDir, file);
3668
3795
  const destPath = path.join(destPersonalitiesDir, file);
3669
- await fs.copyFile(srcPath, destPath);
3670
- installedPersonalities.push(file);
3796
+ const result = await manifestSafeCopy(srcPath, destPath, personalityManifest);
3797
+ if (result.action !== 'skipped') {
3798
+ installedPersonalities.push(file);
3799
+ if (result.hash) personalityManifestUpdates[destPath] = { hash: result.hash, installedAt: new Date().toISOString() };
3800
+ }
3801
+ }
3802
+
3803
+ if (Object.keys(personalityManifestUpdates).length > 0) {
3804
+ await saveManifest(personalityManifestPath, { ...personalityManifest, ...personalityManifestUpdates }).catch(() => { /* best effort */ });
3671
3805
  }
3672
3806
 
3673
3807
  spinner.succeed(chalk.green('Installed personality templates!\n'));
@@ -4570,9 +4704,8 @@ async function handleMcpConfiguration(targetDir, options) {
4570
4704
  * @param {string} targetDir - Base installation directory to validate bmadPath is within
4571
4705
  */
4572
4706
  async function processBmadTtsInjections(bmadPath, targetDir) {
4573
- // Security: Validate bmadPath is within targetDir (not process.cwd() which may differ
4574
- // when called from BMAD's installer via npx with a different cwd)
4575
- if (!isPathSafe(bmadPath, targetDir)) {
4707
+ // Security: bmadPath must be within targetDir OR home dir (BMAD may be installed globally at ~/_bmad)
4708
+ if (!isPathSafe(bmadPath, targetDir) && !isPathSafe(bmadPath, os.homedir())) {
4576
4709
  console.error(chalk.red('⚠️ Security: Invalid BMAD path detected'));
4577
4710
  return;
4578
4711
  }
@@ -4841,14 +4974,17 @@ async function handleBmadIntegration(targetDir, options = {}) {
4841
4974
  }
4842
4975
 
4843
4976
  // Process TTS_INJECTION markers in BMAD files if they exist
4844
- // This handles the case where BMAD was installed before AgentVibes
4845
- await processBmadTtsInjections(bmadDetection.bmadPath, targetDir);
4977
+ // Skip for global home-dir BMAD installs only inject into project-local BMAD
4978
+ if (!bmadDetection.isGlobal) {
4979
+ await processBmadTtsInjections(bmadDetection.bmadPath, targetDir);
4980
+ }
4846
4981
 
4847
4982
  // Create default voice assignments if they don't exist
4848
4983
  await createDefaultBmadVoiceAssignmentsProactive(targetDir);
4849
4984
 
4850
4985
  // Prompt user to inject TTS into BMAD agents (or auto-inject with --yes flag)
4851
- let enableTtsInjection = options.yes; // Auto-enable with --yes flag
4986
+ // Skip injection prompt for global BMAD modifying shared ~/.bmad files from a project install is wrong
4987
+ let enableTtsInjection = bmadDetection.isGlobal ? false : options.yes;
4852
4988
 
4853
4989
  if (!options.yes) {
4854
4990
  console.log(''); // Add spacing
@@ -4980,6 +5116,10 @@ async function updatePersonalityFiles(targetDir, srcPersonalitiesDir) {
4980
5116
  let newPersonalities = 0;
4981
5117
  let updatedPersonalities = 0;
4982
5118
 
5119
+ const manifestPath = getProjectManifestPath(targetDir);
5120
+ const manifest = await loadManifest(manifestPath);
5121
+ const manifestUpdates = {};
5122
+
4983
5123
  for (const file of allPersonalityFiles) {
4984
5124
  const srcPath = path.join(srcPersonalitiesDir, file);
4985
5125
  const stat = await fs.stat(srcPath);
@@ -4989,17 +5129,24 @@ async function updatePersonalityFiles(targetDir, srcPersonalitiesDir) {
4989
5129
  }
4990
5130
 
4991
5131
  const destPath = path.join(destPersonalitiesDir, file);
5132
+ const result = await manifestSafeCopy(srcPath, destPath, manifest);
4992
5133
 
4993
- try {
4994
- await fs.access(destPath);
4995
- await fs.copyFile(srcPath, destPath);
4996
- updatedPersonalities++;
4997
- } catch {
4998
- await fs.copyFile(srcPath, destPath);
5134
+ if (result.action === 'new') {
4999
5135
  newPersonalities++;
5136
+ } else if (result.action === 'updated' || result.action === 'unchanged') {
5137
+ updatedPersonalities++;
5138
+ }
5139
+ // 'skipped' = user modified — leave it alone, .user.bak already saved
5140
+
5141
+ if (result.hash && result.action !== 'skipped') {
5142
+ manifestUpdates[destPath] = { hash: result.hash, installedAt: new Date().toISOString() };
5000
5143
  }
5001
5144
  }
5002
5145
 
5146
+ if (Object.keys(manifestUpdates).length > 0) {
5147
+ await saveManifest(manifestPath, { ...manifest, ...manifestUpdates }).catch(() => { /* best effort */ });
5148
+ }
5149
+
5003
5150
  return { new: newPersonalities, updated: updatedPersonalities };
5004
5151
  }
5005
5152
 
@@ -5031,10 +5178,21 @@ async function updateCommandFiles(targetDir, spinner) {
5031
5178
  const srcCommandsDir = path.join(__dirname, '..', '.claude', 'commands', 'agent-vibes');
5032
5179
  const commandFiles = await fs.readdir(srcCommandsDir);
5033
5180
 
5181
+ const manifestPath = getProjectManifestPath(targetDir);
5182
+ const manifest = await loadManifest(manifestPath);
5183
+ const manifestUpdates = {};
5184
+
5034
5185
  for (const file of commandFiles) {
5035
5186
  const srcPath = path.join(srcCommandsDir, file);
5036
5187
  const destPath = path.join(commandsDir, file);
5037
- await fs.copyFile(srcPath, destPath);
5188
+ const result = await manifestSafeCopy(srcPath, destPath, manifest);
5189
+ if (result.hash && result.action !== 'skipped') {
5190
+ manifestUpdates[destPath] = { hash: result.hash, installedAt: new Date().toISOString() };
5191
+ }
5192
+ }
5193
+
5194
+ if (Object.keys(manifestUpdates).length > 0) {
5195
+ await saveManifest(manifestPath, { ...manifest, ...manifestUpdates }).catch(() => { /* best effort */ });
5038
5196
  }
5039
5197
 
5040
5198
  return commandFiles.length;
@@ -5047,7 +5205,7 @@ async function updateCommandFiles(targetDir, spinner) {
5047
5205
  * on every `npx agentvibes update` regardless of target directory.
5048
5206
  */
5049
5207
  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'];
5050
- 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'];
5208
+ 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'];
5051
5209
 
5052
5210
  /**
5053
5211
  * Update critical hooks in the global ~/.claude/hooks/ directory if it exists.
@@ -5064,13 +5222,20 @@ async function updateGlobalHooks(srcHooksDir, homeDirOverride) {
5064
5222
  // Always ensure the global hooks dir exists so registered $HOME hooks resolve.
5065
5223
  await fs.mkdir(globalHooksDir, { recursive: true });
5066
5224
 
5225
+ const manifestPath = getGlobalManifestPath(homeDir);
5226
+ const manifest = await loadManifest(manifestPath);
5227
+ const manifestUpdates = { ...manifest };
5228
+
5067
5229
  for (const hook of CRITICAL_HOOKS) {
5068
5230
  const destPath = path.join(globalHooksDir, hook);
5069
5231
  const srcPath = path.join(srcHooksDir, hook);
5070
5232
  try {
5071
- await fs.copyFile(srcPath, destPath);
5072
- await fs.chmod(destPath, 0o750);
5073
- updated++;
5233
+ const result = await manifestSafeCopy(srcPath, destPath, manifest);
5234
+ if (result.action !== 'skipped') {
5235
+ if (result.action !== 'unchanged') await fs.chmod(destPath, 0o750);
5236
+ if (result.hash) manifestUpdates[destPath] = { hash: result.hash, installedAt: new Date().toISOString() };
5237
+ updated++;
5238
+ }
5074
5239
  } catch {
5075
5240
  // src missing — skip silently
5076
5241
  }
@@ -5084,16 +5249,74 @@ async function updateGlobalHooks(srcHooksDir, homeDirOverride) {
5084
5249
  const destPath = path.join(globalHooksWindowsDir, hook);
5085
5250
  const srcPath = path.join(srcHooksWindowsDir, hook);
5086
5251
  try {
5087
- await fs.copyFile(srcPath, destPath);
5088
- updated++;
5252
+ const result = await manifestSafeCopy(srcPath, destPath, manifest);
5253
+ if (result.action !== 'skipped') {
5254
+ if (result.hash) manifestUpdates[destPath] = { hash: result.hash, installedAt: new Date().toISOString() };
5255
+ updated++;
5256
+ }
5089
5257
  } catch {
5090
5258
  // src missing — skip silently
5091
5259
  }
5092
5260
  }
5093
5261
 
5262
+ await saveManifest(manifestPath, manifestUpdates).catch(() => { /* best effort */ });
5263
+
5094
5264
  return updated;
5095
5265
  }
5096
5266
 
5267
+ /**
5268
+ * Restart the AgentVibes TTS queue watcher on Windows after an update.
5269
+ * Only runs if the watcher is already installed (~/.agentvibes/tts-watcher.ps1 exists),
5270
+ * meaning the user previously ran setup-ssh-receiver.ps1. Silently skips for users
5271
+ * who don't use the SSH remote receiver.
5272
+ * @param {string} [homeDirOverride] - Override home dir (for testing only)
5273
+ * @returns {Promise<boolean>} true if watcher was restarted, false if skipped
5274
+ */
5275
+ async function restartWatcherIfInstalled(homeDirOverride) {
5276
+ if (!isNativeWindows()) return false;
5277
+
5278
+ const homeDir = homeDirOverride || os.homedir();
5279
+ const watcherDest = path.join(homeDir, '.agentvibes', 'tts-watcher.ps1');
5280
+ const vbsLauncher = path.join(homeDir, '.agentvibes', 'start-watcher.vbs');
5281
+
5282
+ // Only act if the user has the SSH receiver set up
5283
+ try {
5284
+ await fs.access(watcherDest);
5285
+ } catch {
5286
+ return false;
5287
+ }
5288
+
5289
+ const { spawnSync, spawn } = require('child_process');
5290
+
5291
+ // Kill old watcher — use array args to avoid quoting issues
5292
+ spawnSync('powershell.exe', [
5293
+ '-NoProfile', '-Command',
5294
+ 'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like \'*tts-watcher.ps1*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }'
5295
+ ], { stdio: 'ignore', timeout: 8000 });
5296
+
5297
+ // Copy updated watcher from global hooks to the deployed location
5298
+ const watcherSrc = path.join(homeDir, '.claude', 'hooks-windows', 'tts-watcher.ps1');
5299
+ try {
5300
+ await fs.copyFile(watcherSrc, watcherDest);
5301
+ } catch {
5302
+ // src may not exist on a fresh install path — skip copy, the file was already
5303
+ // put there by updateGlobalHooks() earlier in the same run
5304
+ }
5305
+
5306
+ // Restart via VBS launcher (hidden, no console flash) or fall back to direct spawn
5307
+ try {
5308
+ await fs.access(vbsLauncher);
5309
+ spawnSync('wscript.exe', [vbsLauncher], { stdio: 'ignore' });
5310
+ } catch {
5311
+ const ps = spawn('powershell.exe', [
5312
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', watcherDest
5313
+ ], { detached: true, stdio: 'ignore' });
5314
+ ps.unref();
5315
+ }
5316
+
5317
+ return true;
5318
+ }
5319
+
5097
5320
  /**
5098
5321
  * Perform all update operations
5099
5322
  * @param {string} targetDir - Target installation directory
@@ -5120,6 +5343,14 @@ async function performUpdateOperations(targetDir, spinner) {
5120
5343
  console.log(chalk.green(`✓ Updated ${globalHooksUpdated} critical scripts in ~/.claude/hooks/`));
5121
5344
  }
5122
5345
 
5346
+ // On Windows: restart the TTS queue watcher if it was previously installed via
5347
+ // setup-ssh-receiver.ps1. This propagates hook updates without requiring the
5348
+ // user to manually run update-watcher.ps1 after every `npx agentvibes update`.
5349
+ const watcherRestarted = await restartWatcherIfInstalled();
5350
+ if (watcherRestarted) {
5351
+ console.log(chalk.green('✓ TTS watcher restarted with updated scripts'));
5352
+ }
5353
+
5123
5354
  // Update personalities
5124
5355
  spinner.text = 'Updating personality templates...';
5125
5356
  const srcPersonalitiesDir = path.join(__dirname, '..', '.claude', 'personalities');
@@ -5343,7 +5574,7 @@ async function install(options = {}) {
5343
5574
  await installPluginManifest(targetDir, silentSpinner);
5344
5575
  await ensureGitRepo(targetDir, silentSpinner);
5345
5576
 
5346
- // Save provider configuration
5577
+ // Save provider configuration (always written — provider was explicitly chosen in wizard)
5347
5578
  const providerConfigPath = path.join(claudeDir, 'tts-provider.txt');
5348
5579
  await fs.writeFile(providerConfigPath, selectedProvider);
5349
5580
 
@@ -5457,7 +5688,8 @@ Troubleshooting:
5457
5688
  default: defaultVoice = 'Samantha'; break;
5458
5689
  }
5459
5690
  }
5460
- await fs.writeFile(voiceConfigPath, defaultVoice);
5691
+ // Only write voice on first install — preserve user's current voice selection on reinstall
5692
+ try { await fs.access(voiceConfigPath); } catch { await fs.writeFile(voiceConfigPath, defaultVoice); }
5461
5693
 
5462
5694
  // Sync voice + provider to global .agentvibes/config.json so TUI finds them
5463
5695
  // regardless of which directory it's launched from
@@ -5516,16 +5748,18 @@ Troubleshooting:
5516
5748
  await fs.writeFile(translateFile, 'auto', { mode: 0o600 });
5517
5749
  }
5518
5750
 
5519
- // Apply verbosity, personality, pretext
5520
- await fs.writeFile(path.join(claudeDir, 'tts-verbosity.txt'), userConfig.verbosity);
5751
+ // Apply verbosity and personality — only write on first install to preserve user customisation.
5752
+ // To reset these, delete the files manually or use the MCP tools.
5753
+ const verbosityPath = path.join(claudeDir, 'tts-verbosity.txt');
5754
+ try { await fs.access(verbosityPath); } catch { await fs.writeFile(verbosityPath, userConfig.verbosity); }
5521
5755
  if (userConfig.personality && userConfig.personality !== 'none') {
5522
- await fs.writeFile(path.join(claudeDir, 'tts-personality.txt'), userConfig.personality);
5756
+ const personalityPath = path.join(claudeDir, 'tts-personality.txt');
5757
+ try { await fs.access(personalityPath); } catch { await fs.writeFile(personalityPath, userConfig.personality); }
5523
5758
  }
5524
5759
  if (userConfig.pretext && userConfig.pretext.trim()) {
5525
5760
  await fs.writeFile(path.join(configDir, 'tts-pretext.txt'), userConfig.pretext, { mode: 0o600 });
5526
- } else {
5527
- try { await fs.unlink(path.join(configDir, 'tts-pretext.txt')); } catch { /* ok */ }
5528
5761
  }
5762
+ // Do NOT unlink tts-pretext.txt when blank — user may have set it via MCP or manually.
5529
5763
 
5530
5764
  // Apply reverb setting
5531
5765
  const selectedReverb = userConfig.reverb;
@@ -5945,23 +6179,40 @@ program
5945
6179
  try {
5946
6180
  let removedCount = 0;
5947
6181
 
5948
- // Remove project-level files
5949
- const projectPaths = [
6182
+ // Remove project-level files.
6183
+ // AgentVibes-exclusive directories are safe to rm -r.
6184
+ // Shared directories (.claude/hooks, .claude/personalities, etc.) are
6185
+ // cleaned up via the install manifest so user-added files are preserved.
6186
+ const exclusiveDirs = [
5950
6187
  path.join(targetDir, '.claude', 'commands', 'agent-vibes'),
6188
+ path.join(targetDir, '.agentvibes'),
6189
+ ];
6190
+ for (const dirPath of exclusiveDirs) {
6191
+ try { await fs.rm(dirPath, { recursive: true, force: true }); removedCount++; } catch { /* not present */ }
6192
+ }
6193
+
6194
+ // Manifest-based removal for shared directories
6195
+ const projectManifest = await loadManifest(getProjectManifestPath(targetDir));
6196
+ const sharedDirsToTryPrune = [
5951
6197
  path.join(targetDir, '.claude', 'hooks'),
5952
6198
  path.join(targetDir, '.claude', 'hooks-windows'),
5953
6199
  path.join(targetDir, '.claude', 'personalities'),
5954
6200
  path.join(targetDir, '.claude', 'output-styles'),
5955
6201
  path.join(targetDir, '.claude', 'audio'),
5956
- path.join(targetDir, '.agentvibes'),
5957
6202
  ];
5958
-
5959
- for (const dirPath of projectPaths) {
5960
- try {
5961
- await fs.rm(dirPath, { recursive: true, force: true });
5962
- removedCount++;
5963
- } catch (err) {
5964
- // Ignore if directory doesn't exist
6203
+ if (Object.keys(projectManifest).length > 0) {
6204
+ removedCount += await removeManifestFiles(projectManifest, targetDir, sharedDirsToTryPrune);
6205
+ } else {
6206
+ // No manifest (pre-manifest install) fall back to named-file removal for safety
6207
+ const knownProjectFiles = [
6208
+ ...CRITICAL_HOOKS.map(h => path.join(targetDir, '.claude', 'hooks', h)),
6209
+ ...CRITICAL_HOOKS_WINDOWS.map(h => path.join(targetDir, '.claude', 'hooks-windows', h)),
6210
+ ];
6211
+ for (const f of knownProjectFiles) {
6212
+ try { await fs.unlink(f); removedCount++; } catch { /* not present */ }
6213
+ }
6214
+ for (const dir of sharedDirsToTryPrune) {
6215
+ try { await fs.rmdir(dir); } catch { /* not empty or gone */ }
5965
6216
  }
5966
6217
  }
5967
6218
 
@@ -6002,33 +6253,44 @@ program
6002
6253
  removedCount++;
6003
6254
  } catch (_) { /* not present */ }
6004
6255
 
6005
- // Inside ~/.claude/, remove only the subdirs/files AgentVibes installed
6256
+ // Inside ~/.claude/, remove only the files AgentVibes installed.
6257
+ // Use the global manifest so user-added hooks/personalities are preserved.
6258
+ // AgentVibes-exclusive subdir is removed via rm -r; shared dirs via manifest.
6006
6259
  const claudeDir = path.join(homedir, '.claude');
6007
- const agentVibesOwnedInClaude = [
6260
+ try { await fs.rm(path.join(claudeDir, 'commands', 'agent-vibes'), { recursive: true, force: true }); removedCount++; } catch { /* not present */ }
6261
+
6262
+ const globalManifest = await loadManifest(getGlobalManifestPath(homedir));
6263
+ const globalSharedDirs = [
6008
6264
  path.join(claudeDir, 'hooks'),
6009
6265
  path.join(claudeDir, 'hooks-windows'),
6010
- path.join(claudeDir, 'commands', 'agent-vibes'),
6011
6266
  path.join(claudeDir, 'personalities'),
6012
6267
  path.join(claudeDir, 'output-styles'),
6013
6268
  path.join(claudeDir, 'audio'),
6014
6269
  ];
6270
+ if (Object.keys(globalManifest).length > 0) {
6271
+ removedCount += await removeManifestFiles(globalManifest, homedir, globalSharedDirs);
6272
+ } else {
6273
+ // No manifest — remove only known AgentVibes hook files by name
6274
+ const knownGlobalFiles = [
6275
+ ...CRITICAL_HOOKS.map(h => path.join(claudeDir, 'hooks', h)),
6276
+ ...CRITICAL_HOOKS_WINDOWS.map(h => path.join(claudeDir, 'hooks-windows', h)),
6277
+ ];
6278
+ for (const f of knownGlobalFiles) {
6279
+ try { await fs.unlink(f); removedCount++; } catch { /* not present */ }
6280
+ }
6281
+ for (const dir of globalSharedDirs) {
6282
+ try { await fs.rmdir(dir); } catch { /* not empty or gone */ }
6283
+ }
6284
+ }
6285
+
6015
6286
  const agentVibesConfigFiles = [
6016
6287
  'tts-voice.txt', 'tts-provider.txt', 'tts-personality.txt',
6017
6288
  'tts-verbosity.txt', 'tts-translate.txt', 'tts-target-voice.txt',
6018
6289
  'tts-target-language.txt', 'tts-language.txt', 'personalities.json',
6019
6290
  'github-star-reminder.txt', 'piper-voices-dir.txt', 'verbosity.txt',
6020
6291
  ];
6021
-
6022
- for (const dirPath of agentVibesOwnedInClaude) {
6023
- try {
6024
- await fs.rm(dirPath, { recursive: true, force: true });
6025
- removedCount++;
6026
- } catch (_) { /* not present */ }
6027
- }
6028
6292
  for (const fileName of agentVibesConfigFiles) {
6029
- try {
6030
- await fs.unlink(path.join(claudeDir, fileName));
6031
- } catch (_) { /* not present */ }
6293
+ try { await fs.unlink(path.join(claudeDir, fileName)); } catch { /* not present */ }
6032
6294
  }
6033
6295
  }
6034
6296
 
@@ -6440,5 +6702,9 @@ export {
6440
6702
  copyPluginFiles, copyBmadConfigFiles, copyBackgroundMusicFiles,
6441
6703
  copyConfigFiles, copyCodexFiles, configureSessionStartHook, configurePartyModeHook, ensureGitRepo,
6442
6704
  installPluginManifest, checkAndInstallPiper,
6443
- updateGlobalHooks, CRITICAL_HOOKS, CRITICAL_HOOKS_WINDOWS,
6705
+ updateGlobalHooks, updateCommandFiles, updatePersonalityFiles,
6706
+ CRITICAL_HOOKS, CRITICAL_HOOKS_WINDOWS,
6707
+ // Manifest utilities (used by tests and external tooling)
6708
+ getProjectManifestPath, getGlobalManifestPath,
6709
+ loadManifest, saveManifest, computeFileHash, manifestSafeCopy, removeManifestFiles,
6444
6710
  };