vibeaudio 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,993 @@
1
+ /**
2
+ * VibeAudio CLI Wrapper Implementation
3
+ */
4
+
5
+ const os = require("os");
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const { spawn, spawnSync } = require("child_process");
9
+ const {
10
+ AudioPlayer,
11
+ getAudioPath,
12
+ clearCache,
13
+ detectPlayer,
14
+ resolveGenre,
15
+ isKnownGenre,
16
+ normalizeVolume,
17
+ wavDurationMs,
18
+ AVAILABLE_GENRES
19
+ } = require("./player");
20
+ const pkg = require("../package.json");
21
+
22
+ const DEFAULT_GRACE_PERIOD_MS = 1500; // 1.5 second silence grace window
23
+
24
+ const HOOK_ACTIONS = [
25
+ "--install-hooks",
26
+ "--uninstall-hooks",
27
+ "--hook-start",
28
+ "--hook-stop",
29
+ "--hook-tool",
30
+ "--hook-wait",
31
+ "--hook-resume",
32
+ "--hook-end",
33
+ "--daemon"
34
+ ];
35
+
36
+ function printHelp() {
37
+ console.log(`
38
+ \x1b[1m\x1b[36mVibeAudio\x1b[0m v${pkg.version}
39
+ Procedural focus music while your AI coding tools think.
40
+
41
+ \x1b[1mUSAGE:\x1b[0m
42
+ vibe [options] <command> [args...]
43
+ vibeaudio [options] <command> [args...]
44
+
45
+ \x1b[1mEXAMPLES:\x1b[0m
46
+ vibe --install-hooks \x1b[90m# best for interactive agents — music follows thinking\x1b[0m
47
+ vibe --install-hooks --tools codex \x1b[90m# wire up one agent instead of every one found\x1b[0m
48
+ vibe npm test \x1b[90m# wrap any command that exits when it's done\x1b[0m
49
+ vibe claude -p "explain this"
50
+ vibe --genre synthwave claude
51
+ vibe --genre 8bit sleep 5
52
+ vibe --volume 30 npm test
53
+ vibe --preview jazz
54
+
55
+ \x1b[1mOPTIONS:\x1b[0m
56
+ -g, --genre <name> Select genre: lofi (default), synthwave, 8bit, electronic, jazz, zen, piano, drone, random
57
+ -v, --volume <5-100> Set playback volume (default: 40)
58
+ -cv, --chime-volume <5-100> Set independent completion chime volume
59
+ --grace <ms> Silence window before music starts, in ms (default: ${DEFAULT_GRACE_PERIOD_MS})
60
+ --seed <n> Force a specific arrangement (default: derived from the project directory)
61
+ --whisper Preset: 15% volume (late night / headphones)
62
+ --quiet Preset: 25% volume (focus / open office)
63
+ --loud Preset: 75% volume (hear from across the room)
64
+ --no-chime Disable the resolution completion chime
65
+ --no-hud Disable terminal window/tab title animation
66
+ --preview <genre> Play one loop of a genre and exit
67
+ --status Show what is installed, running and detected, then exit
68
+ --stop Stop the background player, then exit
69
+ --mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
70
+ --unmute Resume normal playback, then exit
71
+ --clear-cache Delete cached audio, then exit
72
+ --mcp Run as Model Context Protocol (MCP) server for Desktop apps
73
+ --install-hooks Wire music into your agent's hooks (no wrapper needed)
74
+ --tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen (auto-detect)
75
+ --reactive With --install-hooks: intensity follows the tool in use
76
+ --dry-run With --install-hooks: show what would change, write nothing
77
+ --uninstall-hooks Remove the hooks again, from every agent
78
+ -h, --help Show this help message
79
+ --version Show version
80
+
81
+ \x1b[1mENVIRONMENT:\x1b[0m
82
+ VIBE_GENRE=<name> Set persistent default genre (e.g. export VIBE_GENRE=jazz)
83
+ VIBE_VOLUME=<5-100> Set persistent default volume (e.g. export VIBE_VOLUME=25)
84
+ VIBE_CHIME_VOLUME=<5-100> Set persistent chime volume (e.g. export VIBE_CHIME_VOLUME=60)
85
+ VIBE_GRACE_MS=<ms> Set persistent grace window in ms (e.g. export VIBE_GRACE_MS=3000)
86
+ VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
87
+ VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
88
+ `);
89
+ }
90
+
91
+ /**
92
+ * Flags that consume the next argument. Without this list a trailing
93
+ * `vibe --genre` falls through every branch below and lands in cmdArgs, so the
94
+ * wrapper tries to spawn a program called `--genre` and reports ENOENT on it -
95
+ * the one error message that says nothing about the actual mistake.
96
+ */
97
+ const VALUE_FLAGS = new Set([
98
+ "-g", "--genre",
99
+ "-v", "--volume",
100
+ "-cv", "--chime-volume",
101
+ "--grace",
102
+ "--seed",
103
+ "--tools"
104
+ ]);
105
+
106
+ function parseArgs(argv) {
107
+ const args = argv.slice(2);
108
+ let genre = (process.env.VIBE_GENRE || "lofi").toLowerCase();
109
+
110
+ let volume = normalizeVolume(process.env.VIBE_VOLUME, 0.40);
111
+ let chimeVolume = normalizeVolume(process.env.VIBE_CHIME_VOLUME, null);
112
+
113
+ const envGrace = process.env.VIBE_GRACE_MS ? parseInt(process.env.VIBE_GRACE_MS, 10) : NaN;
114
+ let grace = !isNaN(envGrace) ? Math.max(0, envGrace) : DEFAULT_GRACE_PERIOD_MS;
115
+
116
+ let noChime = false;
117
+ let noHud = false;
118
+ let preview = null;
119
+ let clearCacheFlag = false;
120
+ let statusFlag = false;
121
+ let stopFlag = false;
122
+ let muteFlag = null;
123
+ let muteMinutes = null;
124
+ let hookAction = null;
125
+ let mcp = false;
126
+ let reactive = false;
127
+ let dryRun = false;
128
+ let tools = null;
129
+ let cmdArgs = [];
130
+
131
+ let i = 0;
132
+ while (i < args.length) {
133
+ const arg = args[i];
134
+
135
+ if (VALUE_FLAGS.has(arg) && i + 1 >= args.length) {
136
+ console.error(`\x1b[31m[vibeaudio] ${arg} needs a value.\x1b[0m Run vibe --help for the options.`);
137
+ process.exit(1);
138
+ }
139
+
140
+ if (arg === "-h" || arg === "--help") {
141
+ printHelp();
142
+ process.exit(0);
143
+ }
144
+
145
+ if (arg === "--version") {
146
+ console.log(pkg.version);
147
+ process.exit(0);
148
+ }
149
+
150
+ if (arg === "-g" || arg === "--genre") {
151
+ if (i + 1 < args.length) {
152
+ genre = args[i + 1].toLowerCase();
153
+ i += 2;
154
+ continue;
155
+ }
156
+ }
157
+
158
+ if (arg === "-v" || arg === "--volume") {
159
+ if (i + 1 < args.length) {
160
+ volume = normalizeVolume(args[i + 1], volume);
161
+ i += 2;
162
+ continue;
163
+ }
164
+ }
165
+
166
+ if (arg === "-cv" || arg === "--chime-volume") {
167
+ if (i + 1 < args.length) {
168
+ chimeVolume = normalizeVolume(args[i + 1], chimeVolume);
169
+ i += 2;
170
+ continue;
171
+ }
172
+ }
173
+
174
+ if (arg === "--grace") {
175
+ if (i + 1 < args.length) {
176
+ const parsed = parseInt(args[i + 1], 10);
177
+ if (!isNaN(parsed)) {
178
+ grace = Math.max(0, parsed);
179
+ }
180
+ i += 2;
181
+ continue;
182
+ }
183
+ }
184
+
185
+ if (arg === "--seed") {
186
+ if (i + 1 < args.length) {
187
+ const parsed = parseInt(args[i + 1], 10);
188
+ if (!isNaN(parsed)) process.env.VIBE_SEED = String(parsed >>> 0);
189
+ i += 2;
190
+ continue;
191
+ }
192
+ }
193
+
194
+ if (arg === "--preview") {
195
+ preview = i + 1 < args.length ? args[i + 1].toLowerCase() : genre;
196
+ i += 2;
197
+ continue;
198
+ }
199
+
200
+ if (arg === "--clear-cache") {
201
+ clearCacheFlag = true;
202
+ i += 1;
203
+ continue;
204
+ }
205
+
206
+ if (arg === "--status") {
207
+ statusFlag = true;
208
+ i += 1;
209
+ continue;
210
+ }
211
+
212
+ if (arg === "--stop") {
213
+ stopFlag = true;
214
+ i += 1;
215
+ continue;
216
+ }
217
+
218
+ if (arg === "--mute" || arg === "--unmute") {
219
+ muteFlag = arg === "--mute";
220
+ i += 1;
221
+ // Optional duration: `--mute 30`. Only a bare number counts, so
222
+ // `vibe --mute claude` still parses as a mute plus a command.
223
+ if (muteFlag && args[i] !== undefined && /^\d+$/.test(args[i])) {
224
+ muteMinutes = parseInt(args[i], 10);
225
+ i += 1;
226
+ }
227
+ continue;
228
+ }
229
+
230
+ if (arg === "--mcp") {
231
+ mcp = true;
232
+ i += 1;
233
+ continue;
234
+ }
235
+
236
+ if (HOOK_ACTIONS.includes(arg)) {
237
+ hookAction = arg.slice(2);
238
+ i += 1;
239
+ continue;
240
+ }
241
+
242
+ if (arg === "--reactive") {
243
+ reactive = true;
244
+ i += 1;
245
+ continue;
246
+ }
247
+
248
+ if (arg === "--dry-run") {
249
+ dryRun = true;
250
+ i += 1;
251
+ continue;
252
+ }
253
+
254
+ if (arg === "--tools") {
255
+ tools = args[i + 1];
256
+ i += 2;
257
+ continue;
258
+ }
259
+
260
+ if (arg === "--whisper") {
261
+ volume = 0.15;
262
+ i += 1;
263
+ continue;
264
+ }
265
+
266
+ if (arg === "--quiet") {
267
+ volume = 0.25;
268
+ i += 1;
269
+ continue;
270
+ }
271
+
272
+ if (arg === "--loud") {
273
+ volume = 0.75;
274
+ i += 1;
275
+ continue;
276
+ }
277
+
278
+ if (arg === "--no-chime") {
279
+ noChime = true;
280
+ i += 1;
281
+ continue;
282
+ }
283
+
284
+ if (arg === "--no-hud") {
285
+ noHud = true;
286
+ i += 1;
287
+ continue;
288
+ }
289
+
290
+ // `--` ends our flags: everything after it is the command, even if it
291
+ // starts with a dash. The conventional escape hatch, and the reason the
292
+ // check below can be strict.
293
+ if (arg === "--") {
294
+ cmdArgs = args.slice(i + 1);
295
+ break;
296
+ }
297
+
298
+ // A mistyped flag used to fall through here and be spawned as a program,
299
+ // so `vibe --typo npm test` reported `spawn --typo ENOENT` - an error
300
+ // about the wrong thing entirely. A bare "-" is left alone; some commands
301
+ // use it to mean stdin.
302
+ if (arg.length > 1 && arg.startsWith("-")) {
303
+ console.error(
304
+ `\x1b[31m[vibeaudio] Unknown option '${arg}'.\x1b[0m Run vibe --help for the list.\n` +
305
+ ` If you meant to run a command that starts with a dash: vibe -- ${arg} ...`
306
+ );
307
+ process.exit(1);
308
+ }
309
+
310
+ // Everything from here is the child command
311
+ cmdArgs = args.slice(i);
312
+ break;
313
+ }
314
+
315
+ if (!isKnownGenre(genre)) {
316
+ console.error(
317
+ `\x1b[33m[vibeaudio] Unknown genre '${genre}' — falling back to lofi.\x1b[0m\n` +
318
+ ` Available: ${AVAILABLE_GENRES.join(", ")}, random`
319
+ );
320
+ genre = "lofi";
321
+ }
322
+
323
+ return {
324
+ genre,
325
+ volume,
326
+ chimeVolume,
327
+ grace,
328
+ noChime,
329
+ noHud,
330
+ preview,
331
+ clearCache: clearCacheFlag,
332
+ status: statusFlag,
333
+ stop: stopFlag,
334
+ mute: muteFlag,
335
+ muteMinutes,
336
+ hookAction,
337
+ mcp,
338
+ reactive,
339
+ dryRun,
340
+ tools,
341
+ cmdArgs
342
+ };
343
+ }
344
+
345
+ const { promptInteractive } = require("./interactive");
346
+ const { TerminalHud } = require("./hud");
347
+
348
+ /**
349
+ * Which agents to wire up. Auto-detection is the default because the honest
350
+ * answer to "which of these do I have" lives on the user's disk, not in a
351
+ * flag they have to know to pass; --tools is the override.
352
+ */
353
+ function resolveTargets(explicit) {
354
+ const hooks = require("./hooks");
355
+ const known = Object.keys(hooks.TARGETS);
356
+
357
+ // Absent is null; anything else was typed, empty string included. Testing
358
+ // truthiness instead let `--tools ""` fall through to auto-detection and
359
+ // install for every agent on the machine - the opposite of naming one.
360
+ if (explicit !== null && explicit !== undefined) {
361
+ const ids = explicit.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
362
+ // `--tools ""` or `--tools ,` names nothing, and silently installing
363
+ // everywhere - or reporting success over an empty loop - is worse than
364
+ // saying so.
365
+ if (!ids.length) throw new Error(`--tools named no agent — expected one of: ${known.join(", ")}`);
366
+ const bad = ids.filter((id) => !known.includes(id));
367
+ if (bad.length) throw new Error(`unknown --tools value '${bad.join(", ")}' — expected: ${known.join(", ")}`);
368
+ return ids;
369
+ }
370
+
371
+ const found = hooks.detectTargets();
372
+ if (!found.length) {
373
+ throw new Error(
374
+ `no agent with a hook system found on this machine (looked for ${known.join(", ")}).\n` +
375
+ ` Wrap the command instead: vibe <your-command>`
376
+ );
377
+ }
378
+ return found;
379
+ }
380
+
381
+ /**
382
+ * The one install path. Both --install-hooks and the menu's hooks branch call
383
+ * this, so the two entry points can't drift in what they write or report.
384
+ */
385
+ function installHookTargets(ids, genre, volume, reactive, dryRun = false) {
386
+ const hooks = require("./hooks");
387
+ if (dryRun) console.log(`\x1b[1mDry run\x1b[0m — nothing below is written.\n`);
388
+
389
+ for (const id of ids) {
390
+ const result = hooks.installHooks(genre, volume, null, { reactive, id, dryRun });
391
+ const { file, backup, name, note, events } = result;
392
+
393
+ if (dryRun) {
394
+ printHookPlan(result, hooks.TARGETS[id]);
395
+ } else {
396
+ console.log(`\x1b[32m✔ ${name} hooks installed in ${file}\x1b[0m`);
397
+ if (backup) console.log(` Previous config backed up to ${backup}`);
398
+ }
399
+ console.log(` ${events.start.padEnd(19)}→ music starts (${genre} @ ${Math.round(volume * 100)}%)`);
400
+ console.log(` ${events.stop.padEnd(19)}→ music stops + success chime`);
401
+ if (reactive) {
402
+ console.log(` ${events.tool.padEnd(19)}→ intensity follows the tool in use (reactive mode)`);
403
+ }
404
+ if (events.wait) console.log(` ${events.wait[0].padEnd(19)}→ music pauses + "your turn" chime`);
405
+ if (events.resume) console.log(` ${events.resume[0].padEnd(19)}→ music resumes once you've answered`);
406
+ if (events.failure) console.log(` ${events.failure.padEnd(19)}→ music stops + failure chime (API error)`);
407
+ if (events.end) console.log(` ${events.end.padEnd(19)}→ music stops if that session started it`);
408
+
409
+ if (id === "claude") {
410
+ const slash = hooks.installSlashCommand({ dryRun });
411
+ if (!slash.installed) console.log(` \x1b[33m/vibe not installed: ${slash.reason} (${slash.file})\x1b[0m`);
412
+ else console.log(` ${"/vibe".padEnd(19)}→ ${dryRun ? "would write" : "control the music from inside Claude Code"} (${slash.file})`);
413
+ }
414
+ // Codex will not run a hook it has not been told to trust, so saying
415
+ // "done" without this would be reporting an install that isn't live yet.
416
+ if (note) console.log(` \x1b[33m${note}\x1b[0m`);
417
+ }
418
+
419
+ if (dryRun) {
420
+ console.log(`\n Run again without --dry-run to apply.\n`);
421
+ return;
422
+ }
423
+ // These re-read their hook file per event rather than caching it at startup,
424
+ // verified for each, so an open session picks this up on its next prompt.
425
+ const live = ids.filter((id) => hooks.TARGETS[id].liveReload !== false).map((id) => hooks.TARGETS[id].name);
426
+ if (live.length) console.log(` ${live.join(", ")}: takes effect on your next prompt - no restart needed.`);
427
+ if (ids.length > 1) {
428
+ console.log(` \x1b[90mOne player is shared: whichever agent you prompt last owns the music.\x1b[0m`);
429
+ }
430
+ console.log(` Remove them any time with: vibe --uninstall-hooks\n`);
431
+ }
432
+
433
+ /**
434
+ * What --dry-run shows: for each event, whether our entry would be added,
435
+ * changed, left as it is or removed - read from the file as it is now against
436
+ * the result installHooks computed, so the preview cannot drift from the write.
437
+ */
438
+ function printHookPlan({ file, backup, name, before, after, id }, t) {
439
+ const hooks = require("./hooks");
440
+ const ours = (text) => {
441
+ const map = new Map();
442
+ if (!text) return map;
443
+ for (const [event, entries] of hooks.hookEntries(JSON.parse(text).hooks, t)) {
444
+ const mine = entries.filter((e) => hooks.isVibeHook(e, id));
445
+ if (mine.length) map.set(event, JSON.stringify(mine));
446
+ }
447
+ return map;
448
+ };
449
+ const was = ours(before);
450
+ const will = ours(after);
451
+
452
+ console.log(`\x1b[1m${name}\x1b[0m — would ${before === null ? "create" : "edit"} ${file}`);
453
+ if (backup) console.log(` would back up the current file to ${backup}`);
454
+ for (const event of new Set([...was.keys(), ...will.keys()])) {
455
+ const mark = !was.has(event) ? "\x1b[32m+ add \x1b[0m"
456
+ : !will.has(event) ? "\x1b[31m- remove\x1b[0m"
457
+ : was.get(event) === will.get(event) ? "\x1b[90m= same \x1b[0m" : "\x1b[33m~ change\x1b[0m";
458
+ console.log(` ${mark} ${event}`);
459
+ }
460
+ if (before !== null) console.log(` \x1b[90mEverything else in the file stays as it is.\x1b[0m`);
461
+ }
462
+
463
+ /**
464
+ * Sweeps every target, not just the detected ones - a hook left behind in a
465
+ * config file of a tool that has since been removed is exactly the thing an
466
+ * uninstall is supposed to clear.
467
+ */
468
+ function uninstallHookTargets() {
469
+ const hooks = require("./hooks");
470
+ let total = 0;
471
+
472
+ for (const id of Object.keys(hooks.TARGETS)) {
473
+ const { file, removed } = hooks.uninstallHooks(null, { id });
474
+ total += removed;
475
+ if (removed > 0) {
476
+ console.log(`\x1b[32m✔ Removed ${removed} VibeAudio hook(s) from ${file}\x1b[0m`);
477
+ const backupFile = `${file}.vibeaudio.bak`;
478
+ // The backup is the user's safety net, so point at it rather than
479
+ // deleting it for them - but only when one actually exists.
480
+ if (fs.existsSync(backupFile)) {
481
+ console.log(` Your pre-VibeAudio config backup is kept at ${backupFile}`);
482
+ }
483
+ }
484
+ }
485
+
486
+ if (hooks.uninstallSlashCommand()) console.log(`\x1b[32m✔ Removed the /vibe command from Claude Code\x1b[0m`);
487
+ if (total === 0) console.log(`\x1b[90mNo VibeAudio hooks were installed.\x1b[0m`);
488
+
489
+ // Nothing will ever send a stop event once the hooks are gone, so a daemon
490
+ // left running would play on unsupervised until its 15-minute cap - and
491
+ // `npm rm -g` right after this would take away the only thing that could
492
+ // stop it. Done here rather than in uninstallHooks() so that function stays
493
+ // a pure config edit for tests.
494
+ if (hooks.stopDaemon()) console.log(` Stopped the background player that was still running.`);
495
+ }
496
+
497
+ /**
498
+ * One place to answer "why am I hearing nothing / what is this configured to
499
+ * do". Every line is read from live state rather than assumed, because the
500
+ * usual cause of confusion is a mismatch between config and what is running.
501
+ */
502
+ function printStatus() {
503
+ const hooks = require("./hooks");
504
+ const { playbackDisabled, detectPlayer: detect, CACHE_ROOT } = require("./player");
505
+ const on = (s) => `\x1b[32m${s}\x1b[0m`;
506
+ const off = (s) => `\x1b[90m${s}\x1b[0m`;
507
+
508
+ console.log(`\n\x1b[1m\x1b[36mVibeAudio\x1b[0m v${pkg.version}\n`);
509
+
510
+ const { muteState, muteRemainingText } = require("./player");
511
+ const mute = muteState();
512
+ if (mute !== null) {
513
+ console.log(`\x1b[33m🔇 Muted ${muteRemainingText(mute)}\x1b[0m \x1b[90m(since ${mute.since})\x1b[0m\n`);
514
+ } else if (playbackDisabled()) {
515
+ console.log(`\x1b[33m⏸ Muted by VIBE_DISABLE=${process.env.VIBE_DISABLE}\x1b[0m — automatic playback is off (--preview still works).\n`);
516
+ }
517
+
518
+ // Audio backend
519
+ const backend = detect();
520
+ console.log(`\x1b[1mAudio\x1b[0m`);
521
+ console.log(backend
522
+ ? ` player ${on(backend.cmd)}${backend.volume ? "" : off(" (no volume support — gain is baked into the file)")}`
523
+ : ` player \x1b[31mnone found — VibeAudio runs silently\x1b[0m`);
524
+ console.log(` cache ${dirSize(CACHE_ROOT)}`);
525
+
526
+ // Hooks, per agent that has them
527
+ console.log(`\n\x1b[1mHooks\x1b[0m`);
528
+ const detected = hooks.detectTargets();
529
+ const hooked = new Set();
530
+ for (const id of Object.keys(hooks.TARGETS)) {
531
+ const t = hooks.TARGETS[id];
532
+ const installed = readVibeHooks(t.file(), t);
533
+ if (installed.length) hooked.add(id);
534
+
535
+ if (!installed.length) {
536
+ // "not on this machine" alone is a dead end for someone who has the tool
537
+ // but has never launched it, so every line still says "not installed"
538
+ // and only the reason for skipping it varies.
539
+ const why = detected.includes(id)
540
+ ? `${off("not installed")} — run: vibe --install-hooks`
541
+ : `${off("not installed")} ${off("(not found on this machine)")}`;
542
+ console.log(` ${t.name.padEnd(13)}${why}`);
543
+ continue;
544
+ }
545
+
546
+ console.log(` ${t.name}`);
547
+ for (const { event, command } of installed) {
548
+ const g = /--genre (\S+)/.exec(command);
549
+ const vol = /--volume (\d+)/.exec(command);
550
+ const extra = g ? ` ${g[1]} @ ${vol ? vol[1] : "?"}%${/--reactive/.test(command) ? ", reactive" : ""}` : "";
551
+ console.log(` ${on("✔")} ${extra ? event.padEnd(19) : event}${extra ? off(extra) : ""}`);
552
+ }
553
+ }
554
+
555
+ // Background player
556
+ console.log(`\n\x1b[1mBackground player\x1b[0m`);
557
+ const pid = readDaemonPid(hooks.PID_FILE);
558
+ if (pid === null) {
559
+ console.log(` ${off("not running")}`);
560
+ } else if (!hooks.isOurDaemon(pid)) {
561
+ console.log(` ${off(`stale pid file (${pid} is not ours) — cleared on the next prompt`)}`);
562
+ } else {
563
+ console.log(` ${on(`running`)} pid ${pid}${off(" stop it with: vibe --stop")}`);
564
+ }
565
+
566
+ // Which tools are actually here, and what each one can use
567
+ console.log(`\n\x1b[1mAI tools found\x1b[0m`);
568
+ const found = detectAiTools(hooked);
569
+ if (!found.length) {
570
+ console.log(` ${off("none on PATH — the wrapper still runs any command")}`);
571
+ } else {
572
+ for (const tool of found) {
573
+ console.log(` ${on("✔")} ${tool.name.padEnd(20)}${off(tool.integration)}`);
574
+ }
575
+ }
576
+ console.log();
577
+ }
578
+
579
+ function dirSize(dir) {
580
+ try {
581
+ let total = 0;
582
+ const walk = (d) => {
583
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
584
+ const full = path.join(d, entry.name);
585
+ if (entry.isDirectory()) walk(full);
586
+ else total += fs.statSync(full).size;
587
+ }
588
+ };
589
+ walk(dir);
590
+ return `${(total / 1024 / 1024).toFixed(1)} MB in ${dir}`;
591
+ } catch (e) {
592
+ return "empty";
593
+ }
594
+ }
595
+
596
+ function readVibeHooks(file, t = null) {
597
+ const commands = t ? t.commands : (entry) => (entry.hooks || []).map((h) => h.command);
598
+ const { VIBE_HOOK_FLAG } = require("./hooks");
599
+ try {
600
+ const settings = JSON.parse(fs.readFileSync(file, "utf8"));
601
+ const out = [];
602
+ for (const [event, entries] of Object.entries(settings.hooks || {})) {
603
+ for (const entry of entries || []) {
604
+ for (const command of commands(entry)) {
605
+ if (VIBE_HOOK_FLAG.test(command || "")) out.push({ event, command });
606
+ }
607
+ }
608
+ }
609
+ return out;
610
+ } catch (e) {
611
+ return [];
612
+ }
613
+ }
614
+
615
+ function readDaemonPid(file) {
616
+ try {
617
+ const pid = parseInt(fs.readFileSync(file, "utf8").trim(), 10);
618
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
619
+ } catch (e) {
620
+ return null;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * What is on this machine, and which integration each one can actually use -
626
+ * so the answer to "will this work with my tool" is read off the user's own
627
+ * PATH instead of a table in the README that ages.
628
+ */
629
+ function detectAiTools(hooked = new Set()) {
630
+ const viaHooks = (id) => (hooked.has(id) ? "hooks — installed" : "hooks — run: vibe --install-hooks");
631
+ // Every hook target belongs here, or "AI tools found" contradicts the Hooks
632
+ // section directly above it - which is what a Grok user saw: their hooks
633
+ // listed as installed, and no tools found on the machine.
634
+ const CANDIDATES = [
635
+ { cmd: "claude", name: "Claude Code", integration: viaHooks("claude") },
636
+ { cmd: "codex", name: "Codex", integration: viaHooks("codex") },
637
+ { cmd: "cursor-agent", name: "Cursor", integration: viaHooks("cursor") },
638
+ { cmd: "grok", name: "Grok", integration: viaHooks("grok") },
639
+ { cmd: "gemini", name: "Gemini CLI", integration: viaHooks("gemini") },
640
+ { cmd: "copilot", name: "GitHub Copilot CLI", integration: viaHooks("copilot") },
641
+ { cmd: "qwen", name: "Qwen Code", integration: viaHooks("qwen") },
642
+ { cmd: "aider", name: "Aider", integration: "wrapper — vibe aider" },
643
+ { cmd: "ollama", name: "Ollama", integration: "wrapper — vibe ollama run <model>" }
644
+ ];
645
+ const { isInstalled } = require("./interactive");
646
+ return CANDIDATES.filter((c) => isInstalled(c.cmd));
647
+ }
648
+
649
+ function signalExitCode(signal) {
650
+ return 128 + (os.constants.signals[signal] || 0);
651
+ }
652
+
653
+ function previewGenre(genre, volume) {
654
+ if (!isKnownGenre(genre)) {
655
+ console.error(`\x1b[31m[vibeaudio] Unknown genre '${genre}'.\x1b[0m Available: ${AVAILABLE_GENRES.join(", ")}, random`);
656
+ process.exit(1);
657
+ }
658
+
659
+ const backend = detectPlayer();
660
+ if (!backend) {
661
+ console.error("\x1b[31m[vibeaudio] No supported audio player found — cannot preview.\x1b[0m");
662
+ process.exit(1);
663
+ }
664
+
665
+ const resolved = resolveGenre(genre);
666
+ const audioFile = getAudioPath(resolved, 2);
667
+ const seconds = ((wavDurationMs(audioFile) || 6500) / 1000).toFixed(1);
668
+
669
+ console.log(`\x1b[36m♫ Previewing \x1b[1m${resolved}\x1b[0m\x1b[36m (tier 2, ${seconds}s) — Ctrl+C to stop\x1b[0m`);
670
+ spawnSync(backend.cmd, backend.args(audioFile, volume), { stdio: "ignore" });
671
+ }
672
+
673
+ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun }) {
674
+ const hooks = require("./hooks");
675
+
676
+ switch (action) {
677
+ case "daemon":
678
+ return hooks.runDaemon(genre, volume, { reactive });
679
+
680
+ case "hook-start":
681
+ // The payload names the session (so SessionEnd can tell this session's
682
+ // music from another's) and the transcript (so an interrupt, which
683
+ // fires no hook, can still stop it).
684
+ hooks.readPayload((raw) => hooks.hookStart(genre, volume, { reactive, turn: hooks.newTurn(raw) }));
685
+ return;
686
+
687
+ case "hook-stop":
688
+ // Cursor's stop payload says how the turn ended; the others say nothing
689
+ // and fall back to success. Reading it is what lets the failure chime
690
+ // ever play under hooks - it was hardcoded to success before.
691
+ hooks.readPayload((raw) => {
692
+ hooks.hookStop({
693
+ outcome: hooks.outcomeFromPayload(raw),
694
+ volume,
695
+ chimeVolume,
696
+ noChime
697
+ });
698
+ });
699
+ return;
700
+
701
+ case "hook-tool":
702
+ return hooks.hookTool();
703
+
704
+ case "hook-wait":
705
+ hooks.readPayload((raw) => hooks.hookWait(raw, { volume, chimeVolume, noChime }));
706
+ return;
707
+
708
+ case "hook-resume":
709
+ hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { reactive }));
710
+ return;
711
+
712
+ case "hook-end":
713
+ hooks.readPayload((raw) => hooks.hookEnd(raw));
714
+ return;
715
+
716
+ case "install-hooks":
717
+ return installHookTargets(resolveTargets(tools), genre, volume, reactive, dryRun);
718
+
719
+ case "uninstall-hooks":
720
+ return uninstallHookTargets();
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Which hook target a wrapped command belongs to, if any - `vibe codex` and
726
+ * `vibe claude` are the cases where something other than the wrapper may
727
+ * already be driving the music.
728
+ */
729
+ function hookTargetFor(cmdArgs) {
730
+ const hooks = require("./hooks");
731
+ const base = path.basename(cmdArgs[0] || "");
732
+ return Object.keys(hooks.TARGETS).find((id) => hooks.TARGETS[id].cmd === base) || null;
733
+ }
734
+
735
+ /**
736
+ * An agent that fires our hooks itself would get a second stream layered over
737
+ * the hook daemon's if we wrapped it - and the wrapper's stream is the wrong
738
+ * one: it times the REPL's whole session rather than the model's thinking, so
739
+ * it never stops while you read or type.
740
+ */
741
+ function hooksAlreadyCover(cmdArgs, settingsFile = null) {
742
+ const id = hookTargetFor(cmdArgs);
743
+ if (!id) return false;
744
+
745
+ try {
746
+ const hooks = require("./hooks");
747
+ const file = settingsFile || hooks.TARGETS[id].file();
748
+ if (!fs.existsSync(file)) return false;
749
+
750
+ const settings = JSON.parse(fs.readFileSync(file, "utf8"));
751
+ return Object.values(settings.hooks || {}).some((entries) =>
752
+ // Not `.some(hooks.isVibeHook)` - Array.some would pass the index as the
753
+ // target id and every lookup would throw.
754
+ (entries || []).some((entry) => hooks.isVibeHook(entry, id))
755
+ );
756
+ } catch (e) {
757
+ // Unreadable settings are the hook installer's problem to report, not ours.
758
+ return false;
759
+ }
760
+ }
761
+
762
+ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false) {
763
+ const player = new AudioPlayer();
764
+ const hookDriven = hooksAlreadyCover(cmdArgs);
765
+ const hud = !noHud && !hookDriven ? new TerminalHud(genre) : null;
766
+ const startTime = Date.now();
767
+ let musicStarted = false;
768
+ let finished = false;
769
+
770
+ // `claude -p` and `codex exec` exit when their work does, so the wrapper is
771
+ // right there. An interactive session is the case where process lifetime
772
+ // isn't thinking time.
773
+ const interactiveAgent =
774
+ hookTargetFor(cmdArgs) !== null &&
775
+ !cmdArgs.slice(1).some((a) => a === "-p" || a === "--print" || a === "exec");
776
+
777
+ if (hookDriven) {
778
+ console.error(
779
+ "\x1b[90m[vibeaudio] Hooks are installed for this agent — letting them drive the music, " +
780
+ "so it follows the agent's thinking instead of this session's length.\n" +
781
+ " Change the sound with: vibe --genre <name> --volume <n> --install-hooks\x1b[0m"
782
+ );
783
+ } else if (interactiveAgent) {
784
+ console.error(
785
+ "\x1b[33m[vibeaudio] Wrapping an interactive session: music plays until you quit, " +
786
+ "not just while the agent thinks.\n" +
787
+ " For music that tracks thinking, run: vibe --install-hooks\x1b[0m"
788
+ );
789
+ }
790
+
791
+ // Grace window before triggering audio (silences fast commands)
792
+ const graceTimer = hookDriven ? null : setTimeout(() => {
793
+ musicStarted = true;
794
+ player.start(genre, volume);
795
+ if (hud) hud.start();
796
+ }, grace);
797
+
798
+ const command = cmdArgs[0];
799
+ const commandArgs = cmdArgs.slice(1);
800
+
801
+ const child = spawn(command, commandArgs, {
802
+ stdio: "inherit",
803
+ shell: process.platform === "win32"
804
+ });
805
+
806
+ // Runs exactly once: both the signal path and the close path lead here.
807
+ const cleanup = (code, signal = null) => {
808
+ if (finished) return;
809
+ finished = true;
810
+ clearTimeout(graceTimer);
811
+
812
+ const elapsed = Date.now() - startTime;
813
+ const interrupted = signal === "SIGINT" || signal === "SIGTERM";
814
+ const outcome = code === 0 ? "success" : "failure";
815
+ // A deliberate abort is not an outcome worth chiming about.
816
+ const shouldChime = musicStarted && !noChime && !interrupted && elapsed > grace;
817
+
818
+ if (hud) hud.stop({ outcome, code, interrupted, chimed: shouldChime });
819
+ player.stop({
820
+ playChime: shouldChime,
821
+ outcome,
822
+ volume: Math.min(0.5, volume * 0.9),
823
+ chimeVolume
824
+ });
825
+ process.exit(code);
826
+ };
827
+
828
+ child.on("error", (err) => {
829
+ if (finished) return;
830
+ finished = true;
831
+ clearTimeout(graceTimer);
832
+ if (hud) hud.stop({ outcome: "failure", code: 1 });
833
+ player.stop({ playChime: false });
834
+ console.error(`\x1b[31m[vibeaudio] Failed to start command '${command}':\x1b[0m ${err.message}`);
835
+ process.exit(1);
836
+ });
837
+
838
+ // A signal-killed child reports code === null; mapping that to 0 would claim
839
+ // success for an aborted run.
840
+ child.on("close", (code, signal) => {
841
+ if (signal) cleanup(signalExitCode(signal), signal);
842
+ else cleanup(code !== null ? code : 0);
843
+ });
844
+
845
+ // Relay termination signals, then let the child's exit drive cleanup.
846
+ const relaySignal = (signal) => {
847
+ if (!child.pid || finished) {
848
+ cleanup(signalExitCode(signal), signal);
849
+ return;
850
+ }
851
+ child.kill(signal);
852
+ const fallback = setTimeout(() => cleanup(signalExitCode(signal), signal), 2000);
853
+ if (fallback.unref) fallback.unref();
854
+ };
855
+
856
+ process.on("SIGINT", () => relaySignal("SIGINT"));
857
+ process.on("SIGTERM", () => relaySignal("SIGTERM"));
858
+ }
859
+
860
+ async function run() {
861
+ const {
862
+ genre,
863
+ volume,
864
+ chimeVolume,
865
+ grace,
866
+ noChime,
867
+ noHud,
868
+ preview,
869
+ clearCache: shouldClear,
870
+ status: showStatus,
871
+ stop: shouldStop,
872
+ mute: muteChange,
873
+ muteMinutes,
874
+ hookAction,
875
+ mcp,
876
+ reactive,
877
+ dryRun,
878
+ tools,
879
+ cmdArgs
880
+ } = parseArgs(process.argv);
881
+
882
+ // Parsed as a flag rather than matched anywhere in argv: the loop stops at
883
+ // the first non-flag, so a `--mcp` belonging to the wrapped command
884
+ // (`vibe npm test -- --mcp`) stays the child's and does not start a server.
885
+ if (mcp) {
886
+ const { startMcpServer } = require("./mcp");
887
+ return startMcpServer();
888
+ }
889
+
890
+ if (hookAction) {
891
+ try {
892
+ return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun });
893
+ } catch (e) {
894
+ // Settings problems are the user's to fix — report them, don't stack-trace.
895
+ console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
896
+ process.exit(1);
897
+ }
898
+ }
899
+
900
+ if (reactive) {
901
+ console.error(
902
+ "\x1b[33m[vibeaudio] --reactive only applies to --install-hooks; ignoring it here.\x1b[0m"
903
+ );
904
+ }
905
+
906
+ if (muteChange !== null) {
907
+ const { setMuted, muteRemainingText, DEFAULT_MUTE_MINUTES } = require("./player");
908
+
909
+ if (!muteChange) {
910
+ setMuted(false);
911
+ console.log(`\x1b[32m🔊 Unmuted.\x1b[0m Music returns on your next prompt.\n`);
912
+ return;
913
+ }
914
+
915
+ const minutes = muteMinutes === null ? DEFAULT_MUTE_MINUTES : muteMinutes;
916
+ const state = setMuted(true, minutes);
917
+ // Muting has to silence what is playing right now, not just the next
918
+ // prompt - the whole point is that a call is already ringing.
919
+ const stopped = require("./hooks").stopDaemon();
920
+
921
+ console.log(`\x1b[33m🔇 Muted ${muteRemainingText(state)}.\x1b[0m`);
922
+ if (stopped) console.log(` Stopped the player that was running.`);
923
+ console.log(` Hooks and settings are untouched. Ending it early: vibe --unmute`);
924
+ if (minutes > 0) console.log(` \x1b[90mLonger call? vibe --mute 120 — or vibe --mute 0 to stay off until you say otherwise.\x1b[0m`);
925
+ console.log();
926
+ return;
927
+ }
928
+
929
+ if (shouldStop) {
930
+ const hooks = require("./hooks");
931
+ const stopped = hooks.stopDaemon();
932
+ console.log(
933
+ stopped
934
+ ? "\x1b[32m✔ Stopped the background player.\x1b[0m"
935
+ : "\x1b[90mNothing was playing.\x1b[0m"
936
+ );
937
+ return;
938
+ }
939
+
940
+ if (showStatus) {
941
+ return printStatus();
942
+ }
943
+
944
+ if (shouldClear) {
945
+ console.log(`\x1b[32m[vibeaudio] Cleared cache at ${clearCache()}\x1b[0m`);
946
+ return;
947
+ }
948
+
949
+ if (preview) {
950
+ return previewGenre(preview, volume);
951
+ }
952
+
953
+ if (cmdArgs.length === 0) {
954
+ if (!process.stdin.isTTY) {
955
+ printHelp();
956
+ process.exit(1);
957
+ }
958
+
959
+ // Only the menu itself exits quietly - abandoning it is a normal way to
960
+ // leave. Wrapping the launch too turned every real failure below into a
961
+ // silent exit 0, which is the worst possible thing for a wrapper to do:
962
+ // `vibe && deploy` would chain on a run that never happened.
963
+ let selection;
964
+ try {
965
+ selection = await promptInteractive({
966
+ // Answered per target, once the menu knows which tool was picked -
967
+ // hooksAlreadyCover takes an argv, and TARGETS holds each one's CLI.
968
+ hooksInstalledFor: (id) => hooksAlreadyCover([require("./hooks").TARGETS[id].cmd])
969
+ });
970
+ } catch (e) {
971
+ process.exit(0);
972
+ }
973
+
974
+ const chosenVol = selection.volume !== undefined ? selection.volume : volume;
975
+
976
+ if (selection.installHooks) {
977
+ // The install can refuse (npx checkout) or abort (malformed settings).
978
+ // Either way, say so and still launch the tool the user asked for -
979
+ // they came here to start an agent, not to configure one.
980
+ try {
981
+ installHookTargets([selection.hookTarget], selection.genre, chosenVol, selection.reactive);
982
+ } catch (err) {
983
+ console.error(`\x1b[31m[vibeaudio] ${err.message}\x1b[0m`);
984
+ }
985
+ }
986
+
987
+ return executeCommand(selection.cmd, selection.genre, chosenVol, chimeVolume, grace, noChime, noHud);
988
+ }
989
+
990
+ executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud);
991
+ }
992
+
993
+ module.exports = { run, parseArgs, hooksAlreadyCover };