agentvibes 5.12.0 → 5.13.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.
- package/.agentvibes/install-manifest.json +111 -91
- package/.claude/commands/agent-vibes/commands.json +0 -20
- package/.claude/commands/agent-vibes/unmute.md +6 -2
- package/.claude/config/audio-effects.cfg +6 -6
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/agentvibes-session-id.sh +69 -0
- package/.claude/hooks/bmad-party-speak.sh +20 -4
- package/.claude/hooks/bmad-speak.sh +60 -2
- package/.claude/hooks/bmad-tts-injector.sh +20 -1
- package/.claude/hooks/bmad-voice-manager.sh +25 -3
- package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
- package/.claude/hooks/clawdbot-receiver.sh +19 -1
- package/.claude/hooks/elevenlabs-voices.sh +62 -0
- package/.claude/hooks/kokoro-installer.sh +20 -10
- package/.claude/hooks/language-manager.sh +10 -3
- package/.claude/hooks/party-set-room.sh +71 -0
- package/.claude/hooks/party-stage-roster.py +328 -0
- package/.claude/hooks/personality-manager.sh +19 -2
- package/.claude/hooks/piper-voice-manager.sh +3 -2
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
- package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
- package/.claude/hooks/play-tts-kokoro.sh +33 -6
- package/.claude/hooks/play-tts-soprano.sh +3 -2
- package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
- package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
- package/.claude/hooks/play-tts.sh +66 -61
- package/.claude/hooks/provider-catalog.json +352 -0
- package/.claude/hooks/provider-catalog.sh +161 -0
- package/.claude/hooks/provider-commands.sh +2 -1
- package/.claude/hooks/provider-manager.sh +47 -9
- package/.claude/hooks/python-resolver.sh +117 -0
- package/.claude/hooks/session-id.sh +56 -0
- package/.claude/hooks/session-start-tts.sh +39 -0
- package/.claude/hooks/speed-manager.sh +1 -1
- package/.claude/hooks/translate-manager.sh +3 -2
- package/.claude/hooks/translator.py +1 -1
- package/.claude/hooks/voice-manager.sh +242 -10
- package/.claude/hooks-windows/language-manager.ps1 +7 -1
- package/.claude/hooks-windows/personality-manager.ps1 +16 -1
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
- package/.claude/hooks-windows/play-tts.ps1 +32 -3
- package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
- package/.claude/hooks-windows/provider-manager.ps1 +63 -8
- package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
- package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
- package/.mcp.json +0 -7
- package/README.md +12 -3
- package/RELEASE_NOTES.md +61 -0
- package/mcp-server/server.py +146 -49
- package/mcp-server/test_mcp_correctness.py +20 -2
- package/mcp-server/test_windows_script_parity.py +0 -2
- package/package.json +2 -2
- package/src/cli/list-voices.js +218 -114
- package/src/console/bling.js +71 -0
- package/src/console/music-preview.js +79 -0
- package/src/console/tabs/music-tab.js +16 -39
- package/src/console/tabs/settings-tab.js +195 -13
- package/src/console/tabs/setup-tab.js +9 -34
- package/src/console/tabs/voices-tab.js +83 -14
- package/src/console/widgets/track-picker.js +82 -0
- package/src/installer.js +136 -18
- package/src/services/provider-catalog.js +412 -0
- package/src/services/provider-voice-catalog.js +52 -73
- package/src/services/tts-engine-service.js +29 -0
- package/src/utils/provider-validator.js +62 -12
- package/.claude/commands/agent-vibes/language.md +0 -23
- package/.claude/commands/agent-vibes/learn.md +0 -67
- package/.claude/commands/agent-vibes/replay-target.md +0 -14
- package/.claude/commands/agent-vibes/target-voice.md +0 -26
- package/.claude/commands/agent-vibes/target.md +0 -30
- package/.claude/hooks/learn-manager.sh +0 -492
- package/.claude/hooks/replay-target-audio.sh +0 -95
- package/.claude/hooks-windows/learn-manager.ps1 +0 -241
|
@@ -15,6 +15,40 @@ import { spawn } from 'node:child_process';
|
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { buildAudioEnv, detectWavPlayer, getAllWavPlayers } from '../audio-env.js';
|
|
17
17
|
import { SURNAME_POOL, uniquifyVoiceName } from '../../utils/voice-names.js';
|
|
18
|
+
import { voicesForProvider, ELEVENLABS_VOICES, KOKORO_VOICE_IDS, WINDOWS_SAPI_VOICES, MACOS_VOICES, kokoroGender } from '../../services/provider-voice-catalog.js';
|
|
19
|
+
import { getProvider as _catalogGetProvider } from '../../services/provider-catalog.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve display name / gender / provider label for a NON-Piper catalog voice
|
|
23
|
+
* (Kokoro or ElevenLabs) from the Provider Catalog SSOT. Returns null for Piper
|
|
24
|
+
* ids so getVoiceMeta falls through to its Piper resolution. This keeps the
|
|
25
|
+
* picker's labels/gender correct once non-Piper voices are listed — otherwise
|
|
26
|
+
* a raw ElevenLabs id (EXAVITQu4vr4xnSDxMaL) would render as its own name,
|
|
27
|
+
* gender '—', provider 'Piper'.
|
|
28
|
+
*/
|
|
29
|
+
function catalogVoiceMeta(voiceId) {
|
|
30
|
+
const el = ELEVENLABS_VOICES.find(v => v.id === voiceId);
|
|
31
|
+
if (el) return { displayName: el.name || voiceId, gender: el.gender || '—', provider: 'ElevenLabs' };
|
|
32
|
+
if (KOKORO_VOICE_IDS.includes(voiceId)) {
|
|
33
|
+
// Kokoro ids look like af_heart / am_michael; title-case for display.
|
|
34
|
+
const pretty = voiceId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
35
|
+
return { displayName: pretty, gender: kokoroGender(voiceId) || '—', provider: 'Kokoro' };
|
|
36
|
+
}
|
|
37
|
+
// OS-native voices (Windows SAPI "Microsoft David Desktop", macOS "Samantha").
|
|
38
|
+
const sapi = WINDOWS_SAPI_VOICES.find(v => v.id === voiceId);
|
|
39
|
+
if (sapi) return { displayName: sapi.name, gender: sapi.gender || '—', provider: 'Windows SAPI' };
|
|
40
|
+
const mac = MACOS_VOICES.find(v => v.id === voiceId);
|
|
41
|
+
if (mac) return { displayName: mac.name, gender: mac.gender || '—', provider: 'macOS Say' };
|
|
42
|
+
// Single-voice / native providers whose voice id IS the provider id or alias
|
|
43
|
+
// (soprano, windows-sapi/sapi, macos-say/say). Without this they fall through to
|
|
44
|
+
// the Piper resolution below and get mislabeled "Piper" (the soprano bug).
|
|
45
|
+
const prov = _catalogGetProvider(voiceId);
|
|
46
|
+
if (prov && prov.engineId !== 'piper') {
|
|
47
|
+
const label = prov.displayName.replace(/\s+TTS$/, '');
|
|
48
|
+
return { displayName: label, gender: '—', provider: label };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
18
52
|
|
|
19
53
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
54
|
|
|
@@ -603,6 +637,12 @@ export function getVoiceMeta(voiceId) {
|
|
|
603
637
|
// Lazy-load the catalog so callers from any tab get uniquified names
|
|
604
638
|
loadCatalog();
|
|
605
639
|
|
|
640
|
+
// Non-Piper catalog voices (Kokoro / ElevenLabs) resolve from the Provider
|
|
641
|
+
// Catalog SSOT — real name, real gender, correct provider label — before the
|
|
642
|
+
// Piper-only paths below (which would mislabel them).
|
|
643
|
+
const nonPiper = catalogVoiceMeta(voiceId);
|
|
644
|
+
if (nonPiper) { _metaCache.set(voiceId, nonPiper); return nonPiper; }
|
|
645
|
+
|
|
606
646
|
const ms = parseMultiSpeaker(voiceId);
|
|
607
647
|
if (ms.isMultiSpeaker) {
|
|
608
648
|
if (!ms.speakerName) {
|
|
@@ -987,7 +1027,15 @@ export function createVoicesTab(screen, services) {
|
|
|
987
1027
|
}
|
|
988
1028
|
} catch {}
|
|
989
1029
|
|
|
990
|
-
|
|
1030
|
+
// Route through the engine-aware play-tts for ANY non-Piper provider — remote
|
|
1031
|
+
// transports (ssh-remote / agentvibes-receiver) AND local non-Piper engines
|
|
1032
|
+
// (kokoro / elevenlabs / soprano / macos). Only Piper (and unset = default)
|
|
1033
|
+
// uses the fast local piper path below; play-tts.sh/.ps1 dispatches to the
|
|
1034
|
+
// correct engine so Kokoro/ElevenLabs previews actually synthesize instead of
|
|
1035
|
+
// erroring against a nonexistent <id>.onnx piper model.
|
|
1036
|
+
const _isPiperLocal = (activeProvider === '' || activeProvider === 'piper' || activeProvider === 'windows-piper');
|
|
1037
|
+
if (!_isPiperLocal) {
|
|
1038
|
+
const _remote = _remoteProviders.includes(activeProvider);
|
|
991
1039
|
const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
|
|
992
1040
|
const phrase = `Hi, my name is ${getVoiceMeta(voiceId).displayName}.`;
|
|
993
1041
|
// Hooks live in the AgentVibes package (projectRoot), not the user's project dir.
|
|
@@ -1017,7 +1065,7 @@ export function createVoicesTab(screen, services) {
|
|
|
1017
1065
|
_playingProcess = proc;
|
|
1018
1066
|
_playingVoiceId = voiceId;
|
|
1019
1067
|
refreshDisplay();
|
|
1020
|
-
previewLine.setContent(
|
|
1068
|
+
previewLine.setContent(`{bright-magenta-fg}♪ Synthesizing${_remote ? ' on remote' : ''}...{/bright-magenta-fg}`);
|
|
1021
1069
|
screen.render();
|
|
1022
1070
|
let _stderrBuf = '';
|
|
1023
1071
|
if (proc.stderr) {
|
|
@@ -1688,10 +1736,16 @@ export function createVoicesTab(screen, services) {
|
|
|
1688
1736
|
// genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
|
|
1689
1737
|
const gIcon = genderIconTag(gender);
|
|
1690
1738
|
if (!installed) {
|
|
1691
|
-
// Greyed-out row for uninstalled catalog voices
|
|
1692
|
-
|
|
1739
|
+
// Greyed-out row for uninstalled catalog voices. Use a mid blue-grey (not
|
|
1740
|
+
// bright-black) so the row stays legible when selected — bright-black on the
|
|
1741
|
+
// green selection bg is unreadable. Close the grey wrap around the icon so
|
|
1742
|
+
// its own colors show.
|
|
1743
|
+
return `{#90a4ae-fg} ${star} ${name}{/#90a4ae-fg}${gIcon} {#90a4ae-fg}${provider}{/#90a4ae-fg}`;
|
|
1693
1744
|
}
|
|
1694
|
-
|
|
1745
|
+
// No fg wrap on the name/provider: unselected rows inherit the list's item.fg
|
|
1746
|
+
// (labelFg); the selected row inherits selected.fg (#ffffff bold) so the
|
|
1747
|
+
// highlight is white-on-green and readable, instead of pale text on green.
|
|
1748
|
+
return ` ${star}${dot} ${name}${gIcon} ${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}`;
|
|
1695
1749
|
});
|
|
1696
1750
|
}
|
|
1697
1751
|
|
|
@@ -1742,15 +1796,30 @@ export function createVoicesTab(screen, services) {
|
|
|
1742
1796
|
_metaCache.clear();
|
|
1743
1797
|
}
|
|
1744
1798
|
|
|
1745
|
-
//
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
//
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1799
|
+
// Provider-aware voice list. Piper (disk-discovered) keeps the installer
|
|
1800
|
+
// behavior — installed voices plus uninstalled catalog voices to download.
|
|
1801
|
+
// For Kokoro/ElevenLabs/soprano the voices come from the Provider Catalog
|
|
1802
|
+
// (via voicesForProvider) and are all available (nothing to "install"), so
|
|
1803
|
+
// mark them installed to suppress the download UI.
|
|
1804
|
+
// Prefer the explicitly-chosen Default TTS Engine (config.ttsEngine) over the
|
|
1805
|
+
// provider (which may be a transport like agentvibes-receiver, not an engine).
|
|
1806
|
+
const _vtProvider = configService?.getConfig?.()?.ttsEngine
|
|
1807
|
+
|| providerService?.getActiveProvider?.() || 'piper';
|
|
1808
|
+
const _vtIsDisk = (_vtProvider === 'piper' || _vtProvider === 'windows-piper');
|
|
1809
|
+
if (_vtIsDisk) {
|
|
1810
|
+
// Installed voices (from local disk)
|
|
1811
|
+
const installed = scanInstalledVoices();
|
|
1812
|
+
_installedSet = new Set(installed);
|
|
1813
|
+
|
|
1814
|
+
// Merge: installed voices first, then uninstalled catalog voices
|
|
1815
|
+
const catalogOnly = _catalogEntries
|
|
1816
|
+
.filter(c => !_installedSet.has(c.voiceId))
|
|
1817
|
+
.map(c => c.voiceId);
|
|
1818
|
+
_allVoices = [...installed, ...catalogOnly];
|
|
1819
|
+
} else {
|
|
1820
|
+
_allVoices = voicesForProvider(_vtProvider, { scanInstalledVoices, getVoiceMeta }).map(v => v.id);
|
|
1821
|
+
_installedSet = new Set(_allVoices);
|
|
1822
|
+
}
|
|
1754
1823
|
|
|
1755
1824
|
const active = providerService.getActiveVoiceId();
|
|
1756
1825
|
const favorites = getFavorites(configService);
|
|
@@ -8,11 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
11
12
|
import { destroyList } from './destroy-list.js';
|
|
12
13
|
import { BRAND_PINK } from '../brand-colors.js';
|
|
13
14
|
import { renderHelpBar, selectorTitle } from './help-bar.js';
|
|
14
15
|
import { formatTrackName } from './format-utils.js';
|
|
15
16
|
import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
|
|
17
|
+
import { resolveMusicProvider, spawnMusicRemote } from '../music-preview.js';
|
|
18
|
+
import { playBlingCue } from '../bling.js';
|
|
19
|
+
|
|
20
|
+
// AgentVibes package/repo root — resolves bundled assets (bling cue) and is the
|
|
21
|
+
// package fallback when resolving the active provider for remote forwarding.
|
|
22
|
+
const _WIDGET_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const _PKG_ROOT = path.resolve(_WIDGET_DIR, '..', '..', '..');
|
|
16
24
|
|
|
17
25
|
const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
|
|
18
26
|
let blessed;
|
|
@@ -237,6 +245,10 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
|
|
|
237
245
|
const _spawnEnv = buildAudioEnv();
|
|
238
246
|
let _previewProc = null;
|
|
239
247
|
let _previewTrackId = null;
|
|
248
|
+
// Remote playback is fire-and-forget (the local sender exits in ms while the
|
|
249
|
+
// receiver keeps playing), so the toggle tracks the intended remote track
|
|
250
|
+
// here rather than relying on a live local process.
|
|
251
|
+
let _remotePreviewTrackId = null;
|
|
240
252
|
|
|
241
253
|
function _killPreview() {
|
|
242
254
|
if (_previewProc) {
|
|
@@ -244,9 +256,75 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
|
|
|
244
256
|
_previewProc = null;
|
|
245
257
|
}
|
|
246
258
|
_previewTrackId = null;
|
|
259
|
+
// Stop any remote preview so closing/selecting doesn't leave music playing
|
|
260
|
+
// on the receiver (the local equivalent of killing the player process).
|
|
261
|
+
if (_remotePreviewTrackId) {
|
|
262
|
+
const mp = resolveMusicProvider(_PKG_ROOT);
|
|
263
|
+
if (mp.remote) _sendRemote(mp, { stop: true });
|
|
264
|
+
_remotePreviewTrackId = null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Forward a music track to the receiver (or stop it), mirroring the Music
|
|
270
|
+
* tab's remote-preview behavior. Fire-and-forget; UI errors surface in the
|
|
271
|
+
* title status area. Returns true if the send was launched.
|
|
272
|
+
* @param {{remote:boolean, projectDir:string}} mp
|
|
273
|
+
* @param {{track?:string, stop?:boolean}} opts
|
|
274
|
+
*/
|
|
275
|
+
function _sendRemote(mp, { track = null, stop = false } = {}) {
|
|
276
|
+
let proc;
|
|
277
|
+
try {
|
|
278
|
+
proc = spawnMusicRemote({ packageRoot: _PKG_ROOT, projectDir: mp.projectDir, env: _spawnEnv, track, stop });
|
|
279
|
+
} catch {
|
|
280
|
+
if (!stop) {
|
|
281
|
+
_setStatus(' {red-fg}Remote preview failed{/red-fg} ');
|
|
282
|
+
setTimeout(() => { _setStatus(); }, 4000);
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
let _rerr = '';
|
|
287
|
+
if (proc.stderr) proc.stderr.on('data', d => { _rerr += d.toString(); });
|
|
288
|
+
proc.on('exit', (code) => {
|
|
289
|
+
if (stop || code === 0) return;
|
|
290
|
+
if (_remotePreviewTrackId === track) _remotePreviewTrackId = null;
|
|
291
|
+
const msg = _rerr.trim().split('\n').pop() || 'Remote preview failed';
|
|
292
|
+
_setStatus(` {red-fg}♪ ${msg}{/red-fg} `);
|
|
293
|
+
setTimeout(() => { _setStatus(); }, 4000);
|
|
294
|
+
});
|
|
295
|
+
proc.on('error', () => {
|
|
296
|
+
if (stop) return;
|
|
297
|
+
if (_remotePreviewTrackId === track) _remotePreviewTrackId = null;
|
|
298
|
+
_setStatus(' {red-fg}Remote preview failed{/red-fg} ');
|
|
299
|
+
setTimeout(() => { _setStatus(); }, 4000);
|
|
300
|
+
});
|
|
301
|
+
return true;
|
|
247
302
|
}
|
|
248
303
|
|
|
249
304
|
function _previewTrack(trackFile) {
|
|
305
|
+
// Transport provider: forward to the receiver instead of local playback
|
|
306
|
+
// (which is silent on a headless box) — parity with the Music tab.
|
|
307
|
+
const _mp = resolveMusicProvider(_PKG_ROOT);
|
|
308
|
+
if (_mp.remote) {
|
|
309
|
+
// Toggle off if same track → explicit music-stop signal
|
|
310
|
+
if (_remotePreviewTrackId === trackFile) {
|
|
311
|
+
_sendRemote(_mp, { stop: true });
|
|
312
|
+
_remotePreviewTrackId = null;
|
|
313
|
+
_setStatus();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Bling first (fire-and-forget, plays locally like the voice preview),
|
|
317
|
+
// then forward the track to the receiver.
|
|
318
|
+
playBlingCue(_PKG_ROOT);
|
|
319
|
+
if (_sendRemote(_mp, { track: trackFile })) {
|
|
320
|
+
_remotePreviewTrackId = trackFile;
|
|
321
|
+
const rlabel = tracks.find(t => t.file === trackFile)?.label ?? trackFile;
|
|
322
|
+
_setStatus(` {bright-cyan-fg}♪ ${rlabel} (Space to stop){/bright-cyan-fg} `);
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Local playback ──────────────────────────────────────────────────────
|
|
250
328
|
// Toggle off if same track
|
|
251
329
|
if (_previewTrackId === trackFile) {
|
|
252
330
|
_killPreview();
|
|
@@ -266,6 +344,10 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
|
|
|
266
344
|
return;
|
|
267
345
|
}
|
|
268
346
|
|
|
347
|
+
// Bling first (fire-and-forget) — same readiness cue as the voice preview —
|
|
348
|
+
// then start local playback.
|
|
349
|
+
playBlingCue(_PKG_ROOT);
|
|
350
|
+
|
|
269
351
|
const proc = spawnMp3Player(trackPath, _spawnEnv);
|
|
270
352
|
if (!proc) {
|
|
271
353
|
_setStatus(' {red-fg}No MP3 player found{/red-fg} ');
|
package/src/installer.js
CHANGED
|
@@ -141,6 +141,33 @@ async function saveManifest(manifestPath, files) {
|
|
|
141
141
|
// • dest hash == manifest hash → copy (action: 'updated')
|
|
142
142
|
// • dest hash != manifest hash → skip (action: 'skipped', .user.bak saved)
|
|
143
143
|
// • no manifest entry yet → copy (action: 'updated')
|
|
144
|
+
// Preserve destPath's CURRENT content in a sidecar backup before anything
|
|
145
|
+
// overwrites it. Never clobbers an existing .user.bak — that backup may hold the
|
|
146
|
+
// only surviving copy of an earlier customization; a second backup gets a
|
|
147
|
+
// content-stamped name instead. Returns true only when the current content is
|
|
148
|
+
// safely stored somewhere, so callers can refuse to overwrite when it isn't.
|
|
149
|
+
async function backupUserFile(destPath) {
|
|
150
|
+
const bak = `${destPath}.user.bak`;
|
|
151
|
+
try {
|
|
152
|
+
const currentHash = await computeFileHash(destPath);
|
|
153
|
+
if (!currentHash) return false;
|
|
154
|
+
|
|
155
|
+
const existingHash = await computeFileHash(bak);
|
|
156
|
+
if (existingHash === currentHash) return true; // already backed up
|
|
157
|
+
|
|
158
|
+
if (existingHash) {
|
|
159
|
+
// A different backup exists — keep it and stamp this one.
|
|
160
|
+
await fs.copyFile(destPath, `${destPath}.user.bak.${currentHash.slice(0, 8)}`);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await fs.copyFile(destPath, bak);
|
|
165
|
+
return true;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
async function manifestSafeCopy(srcPath, destPath, manifest) {
|
|
145
172
|
const srcHash = await computeFileHash(srcPath);
|
|
146
173
|
if (!srcHash) return { action: 'skipped', hash: null }; // src missing
|
|
@@ -157,15 +184,29 @@ async function manifestSafeCopy(srcPath, destPath, manifest) {
|
|
|
157
184
|
}
|
|
158
185
|
|
|
159
186
|
const manifestHash = manifest[destPath]?.hash;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
187
|
+
|
|
188
|
+
if (manifestHash) {
|
|
189
|
+
if (destHash !== manifestHash) {
|
|
190
|
+
// User modified the file since we last installed it — preserve it
|
|
191
|
+
await backupUserFile(destPath);
|
|
192
|
+
return { action: 'skipped', hash: destHash };
|
|
193
|
+
}
|
|
194
|
+
// Byte-identical to what we last installed — untouched, ours to update
|
|
195
|
+
await fs.copyFile(srcPath, destPath);
|
|
196
|
+
return { action: 'updated', hash: srcHash };
|
|
164
197
|
}
|
|
165
198
|
|
|
166
|
-
//
|
|
199
|
+
// No manifest entry, and dest differs from what we ship. This is either a
|
|
200
|
+
// user's customized file or an older stock copy predating the manifest — the
|
|
201
|
+
// two are indistinguishable, so we must assume it could be a customization.
|
|
202
|
+
// Back it up before overwriting, and if the backup can't be secured, refuse
|
|
203
|
+
// to overwrite at all rather than destroy the only copy.
|
|
204
|
+
// (CLAUDE.md: never overwrite existing user .claude/ config.)
|
|
205
|
+
if (!(await backupUserFile(destPath))) {
|
|
206
|
+
return { action: 'skipped', hash: destHash };
|
|
207
|
+
}
|
|
167
208
|
await fs.copyFile(srcPath, destPath);
|
|
168
|
-
return { action: 'updated', hash: srcHash };
|
|
209
|
+
return { action: 'updated', hash: srcHash, backedUp: true };
|
|
169
210
|
}
|
|
170
211
|
|
|
171
212
|
// Delete only the files listed in manifest that reside under baseDir.
|
|
@@ -3593,14 +3634,17 @@ async function copyCommandFiles(targetDir, spinner) {
|
|
|
3593
3634
|
*/
|
|
3594
3635
|
function shouldIncludeHookFile(file, stat) {
|
|
3595
3636
|
if (isNativeWindows()) {
|
|
3596
|
-
// Include .ps1 scripts, .py helpers,
|
|
3637
|
+
// Include .ps1 scripts, .py helpers, hooks.json, and the generated
|
|
3638
|
+
// provider-catalog.json; exclude dotfiles and prepare-release
|
|
3597
3639
|
return stat.isFile() &&
|
|
3598
|
-
(file.endsWith('.ps1') || file.endsWith('.py') || file === 'hooks.json'
|
|
3640
|
+
(file.endsWith('.ps1') || file.endsWith('.py') || file === 'hooks.json' ||
|
|
3641
|
+
file === 'provider-catalog.json') &&
|
|
3599
3642
|
!file.includes('prepare-release') &&
|
|
3600
3643
|
!file.startsWith('.');
|
|
3601
3644
|
}
|
|
3602
3645
|
return stat.isFile() &&
|
|
3603
|
-
(file.endsWith('.sh') || file.endsWith('.py') || file === 'hooks.json'
|
|
3646
|
+
(file.endsWith('.sh') || file.endsWith('.py') || file === 'hooks.json' ||
|
|
3647
|
+
file === 'provider-catalog.json') &&
|
|
3604
3648
|
!file.includes('prepare-release') &&
|
|
3605
3649
|
!file.startsWith('.');
|
|
3606
3650
|
}
|
|
@@ -5606,10 +5650,14 @@ async function performUpdateOperations(targetDir, spinner) {
|
|
|
5606
5650
|
console.log(chalk.green(`✓ Updated ${resolverResult.count} resolver bundle files`));
|
|
5607
5651
|
}
|
|
5608
5652
|
|
|
5609
|
-
// Also update critical hooks in global ~/.claude/hooks/ if present (fixes stale installs)
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5653
|
+
// Also update critical hooks in global ~/.claude/hooks/ if present (fixes stale installs).
|
|
5654
|
+
// ALWAYS pass the unix hooks dir: updateGlobalHooks copies CRITICAL_HOOKS (all .sh)
|
|
5655
|
+
// from here and derives hooks-windows itself for CRITICAL_HOOKS_WINDOWS. Passing
|
|
5656
|
+
// hooks-windows on Windows made every .sh lookup miss and hit the "src missing"
|
|
5657
|
+
// catch, so ~/.claude/hooks/*.sh — which git-bash hooks actually execute on
|
|
5658
|
+
// Windows — silently never updated (installs stayed frozen at their first version).
|
|
5659
|
+
const srcGlobalHooksDir = path.join(__dirname, '..', '.claude', 'hooks');
|
|
5660
|
+
const globalHooksUpdated = await updateGlobalHooks(srcGlobalHooksDir);
|
|
5613
5661
|
if (globalHooksUpdated > 0) {
|
|
5614
5662
|
console.log(chalk.green(`✓ Updated ${globalHooksUpdated} critical scripts in ~/.claude/hooks/`));
|
|
5615
5663
|
}
|
|
@@ -5668,6 +5716,16 @@ async function performUpdateOperations(targetDir, spinner) {
|
|
|
5668
5716
|
spinner.text = 'Checking for old configuration...';
|
|
5669
5717
|
await detectAndMigrateOldConfig(targetDir, spinner);
|
|
5670
5718
|
|
|
5719
|
+
// Upgrade safety for the new opt-in injection gate (session-start-tts.sh):
|
|
5720
|
+
// a project that was already installed and talking pre-gate has no marker, so
|
|
5721
|
+
// the gate would newly silence it. Backfill the enable marker for a PROJECT
|
|
5722
|
+
// update so existing users keep their audio. A GLOBAL (home-dir) update stays
|
|
5723
|
+
// opt-in — and we say so, otherwise the user's voice just stops with no clue.
|
|
5724
|
+
if ((await configureInjectionScope(targetDir)) === 'global') {
|
|
5725
|
+
console.log('');
|
|
5726
|
+
warnGlobalInjectionScope();
|
|
5727
|
+
}
|
|
5728
|
+
|
|
5671
5729
|
return {
|
|
5672
5730
|
commandCount,
|
|
5673
5731
|
hookCount: hookResult.count,
|
|
@@ -5721,6 +5779,61 @@ async function updateAgentVibes(targetDir, options) {
|
|
|
5721
5779
|
}
|
|
5722
5780
|
}
|
|
5723
5781
|
|
|
5782
|
+
/**
|
|
5783
|
+
* Configure the session-start injection opt-in marker for this install/update.
|
|
5784
|
+
*
|
|
5785
|
+
* - PROJECT install → drop `.claude/agentvibes-enabled` so session-start-tts.sh
|
|
5786
|
+
* injects the TTS protocol for THIS project (and only this one), preserving the
|
|
5787
|
+
* "install in a project and it just works" experience.
|
|
5788
|
+
* - GLOBAL install (target === home dir) → write nothing; injecting into every
|
|
5789
|
+
* session is exactly the "cacophony of agents" we are preventing.
|
|
5790
|
+
*
|
|
5791
|
+
* Uses `agentvibes-enabled`, NOT `agentvibes-unmuted`, on purpose: the unmuted
|
|
5792
|
+
* marker OVERRIDES a global mute in play-tts.sh, so writing it here would let an
|
|
5793
|
+
* install/update silently defeat a user's `~/.agentvibes-muted` kill-switch.
|
|
5794
|
+
* Non-destructive: never touches an existing mute/unmute/enabled choice.
|
|
5795
|
+
*
|
|
5796
|
+
* @param {string} targetDir - install target
|
|
5797
|
+
* @returns {Promise<'global'|'enabled'|'kept'|'error'>} what happened (for messaging)
|
|
5798
|
+
*/
|
|
5799
|
+
async function configureInjectionScope(targetDir) {
|
|
5800
|
+
try {
|
|
5801
|
+
// path.relative is case-insensitive on win32 and normalizes separators, so a
|
|
5802
|
+
// lowercase drive letter or trailing slash no longer misdetects a home
|
|
5803
|
+
// install; realpath (best-effort) also collapses a symlinked $HOME.
|
|
5804
|
+
let resolvedTarget = path.resolve(targetDir);
|
|
5805
|
+
let resolvedHome = path.resolve(os.homedir());
|
|
5806
|
+
try { resolvedTarget = fsSync.realpathSync.native(resolvedTarget); } catch { /* dir may not exist yet */ }
|
|
5807
|
+
try { resolvedHome = fsSync.realpathSync.native(resolvedHome); } catch { /* ignore */ }
|
|
5808
|
+
if (path.relative(resolvedHome, resolvedTarget) === '') return 'global';
|
|
5809
|
+
|
|
5810
|
+
const claudeDir = path.join(targetDir, '.claude');
|
|
5811
|
+
// Respect any prior explicit choice — enabled OR either mute/unmute marker.
|
|
5812
|
+
for (const m of ['agentvibes-enabled', 'agentvibes-unmuted', 'agentvibes-muted']) {
|
|
5813
|
+
if (await fs.access(path.join(claudeDir, m)).then(() => true).catch(() => false)) {
|
|
5814
|
+
return 'kept';
|
|
5815
|
+
}
|
|
5816
|
+
}
|
|
5817
|
+
await fs.mkdir(claudeDir, { recursive: true });
|
|
5818
|
+
await fs.writeFile(path.join(claudeDir, 'agentvibes-enabled'), '', 'utf8');
|
|
5819
|
+
return 'enabled';
|
|
5820
|
+
} catch (err) {
|
|
5821
|
+
// Never fail install/update over the marker, but don't swallow silently —
|
|
5822
|
+
// a read-only FS here would otherwise produce a mysterious "silent" project.
|
|
5823
|
+
console.log(chalk.gray(` (Could not set TTS injection marker: ${err.code || err.message}. Run /agent-vibes:unmute to enable.)`));
|
|
5824
|
+
return 'error';
|
|
5825
|
+
}
|
|
5826
|
+
}
|
|
5827
|
+
|
|
5828
|
+
// Print the "global install stays silent" guidance (shared by install + update).
|
|
5829
|
+
function warnGlobalInjectionScope() {
|
|
5830
|
+
console.log(chalk.yellow.bold(' ⚠ Global install detected (installed at your home directory)'));
|
|
5831
|
+
console.log(chalk.gray(' TTS stays OFF by default so it will NOT talk in every session at once.'));
|
|
5832
|
+
console.log(chalk.gray(' Enable it in a specific project with: ') + chalk.cyan('/agent-vibes:unmute'));
|
|
5833
|
+
console.log(chalk.gray(' (or re-run the installer inside a project folder, not your home dir).'));
|
|
5834
|
+
console.log('');
|
|
5835
|
+
}
|
|
5836
|
+
|
|
5724
5837
|
// Installation function
|
|
5725
5838
|
async function install(options = {}) {
|
|
5726
5839
|
const currentDir = process.env.INIT_CWD || process.cwd();
|
|
@@ -5842,10 +5955,10 @@ async function install(options = {}) {
|
|
|
5842
5955
|
await copyCodexFiles(targetDir, silentSpinner);
|
|
5843
5956
|
|
|
5844
5957
|
// Populate global ~/.claude/hooks[/-windows]/ so $HOME hook paths resolve
|
|
5845
|
-
// on first install (not just on update).
|
|
5846
|
-
|
|
5847
|
-
const
|
|
5848
|
-
await updateGlobalHooks(
|
|
5958
|
+
// on first install (not just on update). Always the unix hooks dir — see the
|
|
5959
|
+
// note in the update path; updateGlobalHooks derives hooks-windows itself.
|
|
5960
|
+
const srcGlobalHooksDirInstall = path.join(__dirname, '..', '.claude', 'hooks');
|
|
5961
|
+
await updateGlobalHooks(srcGlobalHooksDirInstall);
|
|
5849
5962
|
// Populate global ~/.claude/ resolver bundle too, same reasoning as above (AVI-S8.5 Stage 2)
|
|
5850
5963
|
await updateGlobalResolverBundle();
|
|
5851
5964
|
|
|
@@ -6243,6 +6356,11 @@ def _strip_markdown(text: str) -> str:
|
|
|
6243
6356
|
console.log(chalk.magenta(' \u2661 Sponsor this Developer github.com/sponsors/paulpreibisch'));
|
|
6244
6357
|
console.log('');
|
|
6245
6358
|
|
|
6359
|
+
// Opt-in injection marker (prevents the global-install cacophony). See
|
|
6360
|
+
// configureInjectionScope: project installs enable THIS project; a home-dir
|
|
6361
|
+
// install stays silent. Uses agentvibes-enabled so it can't override a global mute.
|
|
6362
|
+
if ((await configureInjectionScope(targetDir)) === 'global') warnGlobalInjectionScope();
|
|
6363
|
+
|
|
6246
6364
|
if (!(options.nonInteractive || process.env.AGENT_VIBES_NON_INTERACTIVE === '1')) {
|
|
6247
6365
|
// Clean final summary
|
|
6248
6366
|
console.log('');
|
|
@@ -6997,7 +7115,7 @@ export {
|
|
|
6997
7115
|
CRITICAL_HOOKS, CRITICAL_HOOKS_WINDOWS,
|
|
6998
7116
|
// Manifest utilities (used by tests and external tooling)
|
|
6999
7117
|
getProjectManifestPath, getGlobalManifestPath,
|
|
7000
|
-
loadManifest, saveManifest, computeFileHash, manifestSafeCopy, removeManifestFiles,
|
|
7118
|
+
loadManifest, saveManifest, computeFileHash, manifestSafeCopy, backupUserFile, removeManifestFiles,
|
|
7001
7119
|
// Pure helper functions exported for testing
|
|
7002
7120
|
readJsonConfigSafe, backupConfigFile,
|
|
7003
7121
|
isPiperProvider, supportsEmoji, getPersonalityIcon,
|