agentvibes 5.7.0 → 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.
@@ -46,22 +46,25 @@ echo -e "${BLUE}🔍 Checking for BMAD files in .claude/plugins/...${NC}"
46
46
 
47
47
  if [[ -f ".claude/plugins/bmad-voices-enabled.flag" ]]; then
48
48
  echo -e "${YELLOW} Found: bmad-voices-enabled.flag${NC}"
49
- mv .claude/plugins/bmad-voices-enabled.flag .agentvibes/bmad/
50
- echo -e "${GREEN} ✓ Moved to .agentvibes/bmad/${NC}"
49
+ mv -n .claude/plugins/bmad-voices-enabled.flag .agentvibes/bmad/ && \
50
+ echo -e "${GREEN} ✓ Moved to .agentvibes/bmad/${NC}" || \
51
+ echo -e "${YELLOW} ⚠ Already exists in .agentvibes/bmad/ — skipped${NC}"
51
52
  MIGRATED=true
52
53
  fi
53
54
 
54
55
  if [[ -f ".claude/plugins/bmad-party-mode-disabled.flag" ]]; then
55
56
  echo -e "${YELLOW} Found: bmad-party-mode-disabled.flag${NC}"
56
- mv .claude/plugins/bmad-party-mode-disabled.flag .agentvibes/bmad/
57
- echo -e "${GREEN} ✓ Moved to .agentvibes/bmad/${NC}"
57
+ mv -n .claude/plugins/bmad-party-mode-disabled.flag .agentvibes/bmad/ && \
58
+ echo -e "${GREEN} ✓ Moved to .agentvibes/bmad/${NC}" || \
59
+ echo -e "${YELLOW} ⚠ Already exists in .agentvibes/bmad/ — skipped${NC}"
58
60
  MIGRATED=true
59
61
  fi
60
62
 
61
63
  if [[ -f ".claude/plugins/.bmad-previous-settings" ]]; then
62
64
  echo -e "${YELLOW} Found: .bmad-previous-settings${NC}"
63
- mv .claude/plugins/.bmad-previous-settings .agentvibes/bmad/
64
- echo -e "${GREEN} ✓ Moved to .agentvibes/bmad/${NC}"
65
+ mv -n .claude/plugins/.bmad-previous-settings .agentvibes/bmad/ && \
66
+ echo -e "${GREEN} ✓ Moved to .agentvibes/bmad/${NC}" || \
67
+ echo -e "${YELLOW} ⚠ Already exists in .agentvibes/bmad/ — skipped${NC}"
65
68
  MIGRATED=true
66
69
  fi
67
70
 
@@ -72,8 +75,9 @@ echo -e "${BLUE}🔍 Checking for BMAD files in .claude/config/...${NC}"
72
75
 
73
76
  if [[ -f ".claude/config/bmad-voices.md" ]]; then
74
77
  echo -e "${YELLOW} Found: bmad-voices.md${NC}"
75
- mv .claude/config/bmad-voices.md .agentvibes/bmad/
76
- echo -e "${GREEN} ✓ Moved to .agentvibes/bmad/${NC}"
78
+ mv -n .claude/config/bmad-voices.md .agentvibes/bmad/ && \
79
+ echo -e "${GREEN} ✓ Moved to .agentvibes/bmad/${NC}" || \
80
+ echo -e "${YELLOW} ⚠ Already exists in .agentvibes/bmad/ — skipped${NC}"
77
81
  MIGRATED=true
78
82
  fi
79
83
 
@@ -97,29 +101,33 @@ echo -e "${BLUE}🔍 Checking for AgentVibes config in .claude/config/...${NC}"
97
101
 
98
102
  if [[ -f ".claude/config/agentvibes.json" ]]; then
99
103
  echo -e "${YELLOW} Found: agentvibes.json${NC}"
100
- mv .claude/config/agentvibes.json .agentvibes/config/
101
- echo -e "${GREEN} ✓ Moved to .agentvibes/config/${NC}"
104
+ mv -n .claude/config/agentvibes.json .agentvibes/config/ && \
105
+ echo -e "${GREEN} ✓ Moved to .agentvibes/config/${NC}" || \
106
+ echo -e "${YELLOW} ⚠ Already exists in .agentvibes/config/ — skipped${NC}"
102
107
  MIGRATED=true
103
108
  fi
104
109
 
105
110
  if [[ -f ".claude/config/personality-voice-defaults.default.json" ]]; then
106
111
  echo -e "${YELLOW} Found: personality-voice-defaults.default.json${NC}"
107
- mv .claude/config/personality-voice-defaults.default.json .agentvibes/config/
108
- echo -e "${GREEN} ✓ Moved to .agentvibes/config/${NC}"
112
+ mv -n .claude/config/personality-voice-defaults.default.json .agentvibes/config/ && \
113
+ echo -e "${GREEN} ✓ Moved to .agentvibes/config/${NC}" || \
114
+ echo -e "${YELLOW} ⚠ Already exists in .agentvibes/config/ — skipped${NC}"
109
115
  MIGRATED=true
110
116
  fi
111
117
 
112
118
  if [[ -f ".claude/config/personality-voice-defaults.json" ]]; then
113
119
  echo -e "${YELLOW} Found: personality-voice-defaults.json${NC}"
114
- mv .claude/config/personality-voice-defaults.json .agentvibes/config/
115
- echo -e "${GREEN} ✓ Moved to .agentvibes/config/${NC}"
120
+ mv -n .claude/config/personality-voice-defaults.json .agentvibes/config/ && \
121
+ echo -e "${GREEN} ✓ Moved to .agentvibes/config/${NC}" || \
122
+ echo -e "${YELLOW} ⚠ Already exists in .agentvibes/config/ — skipped${NC}"
116
123
  MIGRATED=true
117
124
  fi
118
125
 
119
126
  if [[ -f ".claude/config/README-personality-defaults.md" ]]; then
120
127
  echo -e "${YELLOW} Found: README-personality-defaults.md${NC}"
121
- mv .claude/config/README-personality-defaults.md .agentvibes/config/
122
- echo -e "${GREEN} ✓ Moved to .agentvibes/config/${NC}"
128
+ mv -n .claude/config/README-personality-defaults.md .agentvibes/config/ && \
129
+ echo -e "${GREEN} ✓ Moved to .agentvibes/config/${NC}" || \
130
+ echo -e "${YELLOW} ⚠ Already exists in .agentvibes/config/ — skipped${NC}"
123
131
  MIGRATED=true
124
132
  fi
125
133
 
@@ -276,6 +276,13 @@ case "$1" in
276
276
  exit 1
277
277
  fi
278
278
 
279
+ # Validate name: alphanumeric, hyphens, underscores only — no path traversal
280
+ if [[ ! "$NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
281
+ echo "❌ Invalid personality name '$NAME'"
282
+ echo " Name must contain only letters, numbers, hyphens, and underscores"
283
+ exit 1
284
+ fi
285
+
279
286
  FILE="$PERSONALITIES_DIR/${NAME}.md"
280
287
  if [[ -f "$FILE" ]]; then
281
288
  echo "❌ Personality '$NAME' already exists"
@@ -306,8 +313,8 @@ Describe how the AI should generate messages for this personality.
306
313
  - "Example response 2"
307
314
  EOF
308
315
 
309
- # Replace NAME with actual name
310
- sed -i "s/NAME/$NAME/g" "$FILE"
316
+ # Replace NAME placeholder — use | as delimiter to avoid issues with / in paths
317
+ sed -i "s|NAME|${NAME}|g" "$FILE"
311
318
 
312
319
  echo "✅ Created new personality: $NAME"
313
320
  echo "📝 Edit the file: $FILE"
@@ -327,6 +334,12 @@ EOF
327
334
  exit 1
328
335
  fi
329
336
 
337
+ if [[ ! "$NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
338
+ echo "❌ Invalid personality name '$NAME'"
339
+ echo " Name must contain only letters, numbers, hyphens, and underscores"
340
+ exit 1
341
+ fi
342
+
330
343
  FILE="$PERSONALITIES_DIR/${NAME}.md"
331
344
  if [[ ! -f "$FILE" ]]; then
332
345
  echo "❌ Personality '$NAME' not found"
@@ -335,6 +335,12 @@ if [[ -n "$VOICE_OVERRIDE" ]]; then
335
335
  esac
336
336
  fi
337
337
 
338
+ # Emit resolved voice and provider in verbose mode (used by tests and diagnostics)
339
+ if [[ "${AGENTVIBES_VERBOSE:-0}" == "1" ]]; then
340
+ [[ -n "${VOICE_OVERRIDE:-}" ]] && echo "voice=${VOICE_OVERRIDE}" >&2
341
+ echo "provider=${ACTIVE_PROVIDER}" >&2
342
+ fi
343
+
338
344
  # @function speak_text
339
345
  # @intent Route text to appropriate TTS provider
340
346
  # @why Reusable function for speaking, used by both single and learning modes
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Publish](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml/badge.svg)](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml)
12
12
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
13
13
 
14
- **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.7.0
14
+ **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.7.1
15
15
 
16
16
  ---
17
17
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "agentvibes",
4
- "version": "5.7.0",
4
+ "version": "5.7.1",
5
5
  "description": "Now your AI Agents can finally talk back! Professional TTS voice for Claude Code, Claude Desktop (via MCP), and Clawdbot with multi-provider support.",
6
6
  "homepage": "https://agentvibes.org",
7
7
  "keywords": [
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'));
@@ -4982,6 +5116,10 @@ async function updatePersonalityFiles(targetDir, srcPersonalitiesDir) {
4982
5116
  let newPersonalities = 0;
4983
5117
  let updatedPersonalities = 0;
4984
5118
 
5119
+ const manifestPath = getProjectManifestPath(targetDir);
5120
+ const manifest = await loadManifest(manifestPath);
5121
+ const manifestUpdates = {};
5122
+
4985
5123
  for (const file of allPersonalityFiles) {
4986
5124
  const srcPath = path.join(srcPersonalitiesDir, file);
4987
5125
  const stat = await fs.stat(srcPath);
@@ -4991,17 +5129,24 @@ async function updatePersonalityFiles(targetDir, srcPersonalitiesDir) {
4991
5129
  }
4992
5130
 
4993
5131
  const destPath = path.join(destPersonalitiesDir, file);
5132
+ const result = await manifestSafeCopy(srcPath, destPath, manifest);
4994
5133
 
4995
- try {
4996
- await fs.access(destPath);
4997
- await fs.copyFile(srcPath, destPath);
4998
- updatedPersonalities++;
4999
- } catch {
5000
- await fs.copyFile(srcPath, destPath);
5134
+ if (result.action === 'new') {
5001
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() };
5002
5143
  }
5003
5144
  }
5004
5145
 
5146
+ if (Object.keys(manifestUpdates).length > 0) {
5147
+ await saveManifest(manifestPath, { ...manifest, ...manifestUpdates }).catch(() => { /* best effort */ });
5148
+ }
5149
+
5005
5150
  return { new: newPersonalities, updated: updatedPersonalities };
5006
5151
  }
5007
5152
 
@@ -5033,10 +5178,21 @@ async function updateCommandFiles(targetDir, spinner) {
5033
5178
  const srcCommandsDir = path.join(__dirname, '..', '.claude', 'commands', 'agent-vibes');
5034
5179
  const commandFiles = await fs.readdir(srcCommandsDir);
5035
5180
 
5181
+ const manifestPath = getProjectManifestPath(targetDir);
5182
+ const manifest = await loadManifest(manifestPath);
5183
+ const manifestUpdates = {};
5184
+
5036
5185
  for (const file of commandFiles) {
5037
5186
  const srcPath = path.join(srcCommandsDir, file);
5038
5187
  const destPath = path.join(commandsDir, file);
5039
- 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 */ });
5040
5196
  }
5041
5197
 
5042
5198
  return commandFiles.length;
@@ -5066,13 +5222,20 @@ async function updateGlobalHooks(srcHooksDir, homeDirOverride) {
5066
5222
  // Always ensure the global hooks dir exists so registered $HOME hooks resolve.
5067
5223
  await fs.mkdir(globalHooksDir, { recursive: true });
5068
5224
 
5225
+ const manifestPath = getGlobalManifestPath(homeDir);
5226
+ const manifest = await loadManifest(manifestPath);
5227
+ const manifestUpdates = { ...manifest };
5228
+
5069
5229
  for (const hook of CRITICAL_HOOKS) {
5070
5230
  const destPath = path.join(globalHooksDir, hook);
5071
5231
  const srcPath = path.join(srcHooksDir, hook);
5072
5232
  try {
5073
- await fs.copyFile(srcPath, destPath);
5074
- await fs.chmod(destPath, 0o750);
5075
- 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
+ }
5076
5239
  } catch {
5077
5240
  // src missing — skip silently
5078
5241
  }
@@ -5086,13 +5249,18 @@ async function updateGlobalHooks(srcHooksDir, homeDirOverride) {
5086
5249
  const destPath = path.join(globalHooksWindowsDir, hook);
5087
5250
  const srcPath = path.join(srcHooksWindowsDir, hook);
5088
5251
  try {
5089
- await fs.copyFile(srcPath, destPath);
5090
- 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
+ }
5091
5257
  } catch {
5092
5258
  // src missing — skip silently
5093
5259
  }
5094
5260
  }
5095
5261
 
5262
+ await saveManifest(manifestPath, manifestUpdates).catch(() => { /* best effort */ });
5263
+
5096
5264
  return updated;
5097
5265
  }
5098
5266
 
@@ -5406,7 +5574,7 @@ async function install(options = {}) {
5406
5574
  await installPluginManifest(targetDir, silentSpinner);
5407
5575
  await ensureGitRepo(targetDir, silentSpinner);
5408
5576
 
5409
- // Save provider configuration
5577
+ // Save provider configuration (always written — provider was explicitly chosen in wizard)
5410
5578
  const providerConfigPath = path.join(claudeDir, 'tts-provider.txt');
5411
5579
  await fs.writeFile(providerConfigPath, selectedProvider);
5412
5580
 
@@ -5520,7 +5688,8 @@ Troubleshooting:
5520
5688
  default: defaultVoice = 'Samantha'; break;
5521
5689
  }
5522
5690
  }
5523
- 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); }
5524
5693
 
5525
5694
  // Sync voice + provider to global .agentvibes/config.json so TUI finds them
5526
5695
  // regardless of which directory it's launched from
@@ -5579,16 +5748,18 @@ Troubleshooting:
5579
5748
  await fs.writeFile(translateFile, 'auto', { mode: 0o600 });
5580
5749
  }
5581
5750
 
5582
- // Apply verbosity, personality, pretext
5583
- 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); }
5584
5755
  if (userConfig.personality && userConfig.personality !== 'none') {
5585
- 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); }
5586
5758
  }
5587
5759
  if (userConfig.pretext && userConfig.pretext.trim()) {
5588
5760
  await fs.writeFile(path.join(configDir, 'tts-pretext.txt'), userConfig.pretext, { mode: 0o600 });
5589
- } else {
5590
- try { await fs.unlink(path.join(configDir, 'tts-pretext.txt')); } catch { /* ok */ }
5591
5761
  }
5762
+ // Do NOT unlink tts-pretext.txt when blank — user may have set it via MCP or manually.
5592
5763
 
5593
5764
  // Apply reverb setting
5594
5765
  const selectedReverb = userConfig.reverb;
@@ -6008,23 +6179,40 @@ program
6008
6179
  try {
6009
6180
  let removedCount = 0;
6010
6181
 
6011
- // Remove project-level files
6012
- 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 = [
6013
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 = [
6014
6197
  path.join(targetDir, '.claude', 'hooks'),
6015
6198
  path.join(targetDir, '.claude', 'hooks-windows'),
6016
6199
  path.join(targetDir, '.claude', 'personalities'),
6017
6200
  path.join(targetDir, '.claude', 'output-styles'),
6018
6201
  path.join(targetDir, '.claude', 'audio'),
6019
- path.join(targetDir, '.agentvibes'),
6020
6202
  ];
6021
-
6022
- for (const dirPath of projectPaths) {
6023
- try {
6024
- await fs.rm(dirPath, { recursive: true, force: true });
6025
- removedCount++;
6026
- } catch (err) {
6027
- // 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 */ }
6028
6216
  }
6029
6217
  }
6030
6218
 
@@ -6065,33 +6253,44 @@ program
6065
6253
  removedCount++;
6066
6254
  } catch (_) { /* not present */ }
6067
6255
 
6068
- // 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.
6069
6259
  const claudeDir = path.join(homedir, '.claude');
6070
- 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 = [
6071
6264
  path.join(claudeDir, 'hooks'),
6072
6265
  path.join(claudeDir, 'hooks-windows'),
6073
- path.join(claudeDir, 'commands', 'agent-vibes'),
6074
6266
  path.join(claudeDir, 'personalities'),
6075
6267
  path.join(claudeDir, 'output-styles'),
6076
6268
  path.join(claudeDir, 'audio'),
6077
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
+
6078
6286
  const agentVibesConfigFiles = [
6079
6287
  'tts-voice.txt', 'tts-provider.txt', 'tts-personality.txt',
6080
6288
  'tts-verbosity.txt', 'tts-translate.txt', 'tts-target-voice.txt',
6081
6289
  'tts-target-language.txt', 'tts-language.txt', 'personalities.json',
6082
6290
  'github-star-reminder.txt', 'piper-voices-dir.txt', 'verbosity.txt',
6083
6291
  ];
6084
-
6085
- for (const dirPath of agentVibesOwnedInClaude) {
6086
- try {
6087
- await fs.rm(dirPath, { recursive: true, force: true });
6088
- removedCount++;
6089
- } catch (_) { /* not present */ }
6090
- }
6091
6292
  for (const fileName of agentVibesConfigFiles) {
6092
- try {
6093
- await fs.unlink(path.join(claudeDir, fileName));
6094
- } catch (_) { /* not present */ }
6293
+ try { await fs.unlink(path.join(claudeDir, fileName)); } catch { /* not present */ }
6095
6294
  }
6096
6295
  }
6097
6296
 
@@ -6503,5 +6702,9 @@ export {
6503
6702
  copyPluginFiles, copyBmadConfigFiles, copyBackgroundMusicFiles,
6504
6703
  copyConfigFiles, copyCodexFiles, configureSessionStartHook, configurePartyModeHook, ensureGitRepo,
6505
6704
  installPluginManifest, checkAndInstallPiper,
6506
- 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,
6507
6710
  };
@@ -481,19 +481,54 @@ export async function removeCopilotMcp(targetDir) {
481
481
  }
482
482
  }
483
483
 
484
+ const AGENTVIBES_MARKER_START = '<!-- BEGIN AGENTVIBES -->';
485
+ const AGENTVIBES_MARKER_END = '<!-- END AGENTVIBES -->';
486
+
487
+ function wrapWithMarkers(content) {
488
+ return `${AGENTVIBES_MARKER_START}\n${content.trim()}\n${AGENTVIBES_MARKER_END}\n`;
489
+ }
490
+
491
+ function injectMarkerBlock(existing, newBlock) {
492
+ const start = existing.indexOf(AGENTVIBES_MARKER_START);
493
+ const end = existing.indexOf(AGENTVIBES_MARKER_END);
494
+ if (start !== -1 && end !== -1) {
495
+ return existing.substring(0, start) + newBlock + existing.substring(end + AGENTVIBES_MARKER_END.length + 1);
496
+ }
497
+ return existing.trimEnd() + (existing.trim() ? '\n\n' : '') + newBlock;
498
+ }
499
+
500
+ function removeMarkerBlock(existing) {
501
+ const start = existing.indexOf(AGENTVIBES_MARKER_START);
502
+ const end = existing.indexOf(AGENTVIBES_MARKER_END);
503
+ if (start === -1 || end === -1) return existing;
504
+ const before = existing.substring(0, start).trimEnd();
505
+ const after = existing.substring(end + AGENTVIBES_MARKER_END.length).replace(/^\n+/, '\n');
506
+ return (before + after).trimEnd() + '\n';
507
+ }
508
+
484
509
  export async function installCopilotInstructions(targetDir, packageDir) {
485
510
  const destPath = path.join(targetDir, '.github', 'copilot-instructions.md');
486
511
  const srcPath = path.join(packageDir, '.github', 'copilot-instructions.md');
487
512
  try {
488
513
  await fs.mkdir(path.join(targetDir, '.github'), { recursive: true });
489
- const content = await fs.readFile(srcPath, 'utf8');
490
- await fs.writeFile(destPath, content);
514
+ const srcContent = await fs.readFile(srcPath, 'utf8');
515
+ let existing = '';
516
+ try { existing = await fs.readFile(destPath, 'utf8'); } catch { /* new file */ }
517
+ const block = wrapWithMarkers(srcContent);
518
+ await fs.writeFile(destPath, injectMarkerBlock(existing, block));
491
519
  } catch { /* best effort */ }
492
520
  }
493
521
 
494
522
  export async function removeCopilotInstructions(targetDir) {
523
+ const destPath = path.join(targetDir, '.github', 'copilot-instructions.md');
495
524
  try {
496
- await fs.unlink(path.join(targetDir, '.github', 'copilot-instructions.md'));
525
+ const existing = await fs.readFile(destPath, 'utf8');
526
+ const updated = removeMarkerBlock(existing);
527
+ if (updated.trim()) {
528
+ await fs.writeFile(destPath, updated);
529
+ } else {
530
+ await fs.unlink(destPath);
531
+ }
497
532
  } catch { /* already gone */ }
498
533
  }
499
534
 
@@ -582,10 +617,15 @@ export function buildCodexToml(existingContent = '') {
582
617
  export async function installCodexInstructions(targetDir, packageDir) {
583
618
  const srcPath = path.join(packageDir, '.codex', 'AGENTS.md');
584
619
  try {
585
- const content = await fs.readFile(srcPath, 'utf8');
620
+ const srcContent = await fs.readFile(srcPath, 'utf8');
621
+ const block = wrapWithMarkers(srcContent);
586
622
  await fs.mkdir(path.join(targetDir, '.codex'), { recursive: true });
587
- await fs.writeFile(path.join(targetDir, '.codex', 'AGENTS.md'), content);
588
- await fs.writeFile(path.join(targetDir, 'AGENTS.md'), content);
623
+
624
+ for (const dest of [path.join(targetDir, '.codex', 'AGENTS.md'), path.join(targetDir, 'AGENTS.md')]) {
625
+ let existing = '';
626
+ try { existing = await fs.readFile(dest, 'utf8'); } catch { /* new file */ }
627
+ await fs.writeFile(dest, injectMarkerBlock(existing, block));
628
+ }
589
629
  } catch { /* best effort */ }
590
630
  }
591
631
 
@@ -604,12 +644,17 @@ export async function installCodexHooks(targetDir, packageDir) {
604
644
  }
605
645
 
606
646
  export async function removeCodexInstructions(targetDir) {
607
- try {
608
- await fs.unlink(path.join(targetDir, '.codex', 'AGENTS.md'));
609
- } catch { /* already gone */ }
610
- try {
611
- await fs.unlink(path.join(targetDir, 'AGENTS.md'));
612
- } catch { /* already gone */ }
647
+ for (const dest of [path.join(targetDir, '.codex', 'AGENTS.md'), path.join(targetDir, 'AGENTS.md')]) {
648
+ try {
649
+ const existing = await fs.readFile(dest, 'utf8');
650
+ const updated = removeMarkerBlock(existing);
651
+ if (updated.trim()) {
652
+ await fs.writeFile(dest, updated);
653
+ } else {
654
+ await fs.unlink(dest);
655
+ }
656
+ } catch { /* already gone */ }
657
+ }
613
658
  }
614
659
 
615
660
  export async function removeCodexHooks(targetDir) {