vibeaudio 0.4.0 → 0.6.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/src/cli.js CHANGED
@@ -14,6 +14,10 @@ const {
14
14
  resolveGenre,
15
15
  isKnownGenre,
16
16
  normalizeVolume,
17
+ loadConfig,
18
+ saveConfig,
19
+ projectSettings,
20
+ saveProjectConfig,
17
21
  wavDurationMs,
18
22
  AVAILABLE_GENRES
19
23
  } = require("./player");
@@ -44,6 +48,7 @@ Procedural focus music while your AI coding tools think.
44
48
 
45
49
  \x1b[1mEXAMPLES:\x1b[0m
46
50
  vibe --install-hooks \x1b[90m# best for interactive agents — music follows thinking\x1b[0m
51
+ vibe --genre jazz --volume 25 \x1b[90m# save your default — reaches hooks, wrapper and MCP\x1b[0m
47
52
  vibe --install-hooks --tools codex \x1b[90m# wire up one agent instead of every one found\x1b[0m
48
53
  vibe npm test \x1b[90m# wrap any command that exits when it's done\x1b[0m
49
54
  vibe claude -p "explain this"
@@ -51,12 +56,14 @@ Procedural focus music while your AI coding tools think.
51
56
  vibe --genre 8bit sleep 5
52
57
  vibe --volume 30 npm test
53
58
  vibe --preview jazz
59
+ vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre)\x1b[0m
54
60
 
55
61
  \x1b[1mOPTIONS:\x1b[0m
56
62
  -g, --genre <name> Select genre: lofi (default), synthwave, 8bit, electronic, jazz, zen, piano, drone, random
57
63
  -v, --volume <5-100> Set playback volume (default: 40)
58
64
  -cv, --chime-volume <5-100> Set independent completion chime volume
59
65
  --grace <ms> Silence window before music starts, in ms (default: ${DEFAULT_GRACE_PERIOD_MS})
66
+ --here With a saved setting: this directory only, not everywhere
60
67
  --seed <n> Force a specific arrangement (default: derived from the project directory)
61
68
  --whisper Preset: 15% volume (late night / headphones)
62
69
  --quiet Preset: 25% volume (focus / open office)
@@ -78,11 +85,22 @@ Procedural focus music while your AI coding tools think.
78
85
  -h, --help Show this help message
79
86
  --version Show version
80
87
 
88
+ \x1b[1mSETTINGS:\x1b[0m
89
+ A genre or volume with no command after it is saved as your default:
90
+
91
+ vibe --genre zen \x1b[90m# applies on the next prompt, nothing to reinstall\x1b[0m
92
+ vibe --genre zen --here \x1b[90m# this project only — the default stays as it was\x1b[0m
93
+
94
+ Saved to ~/.vibeaudio/config.json and read by the wrapper, your agent hooks
95
+ and the MCP server alike. A flag beats an environment variable beats a
96
+ \x1b[1m--here\x1b[0m setting beats the global one, so a one-off
97
+ \x1b[1mvibe --genre 8bit npm test\x1b[0m stays a one-off.
98
+
81
99
  \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)
100
+ VIBE_GENRE=<name> Override the saved genre for this shell
101
+ VIBE_VOLUME=<5-100> Override the saved volume for this shell
102
+ VIBE_CHIME_VOLUME=<5-100> Override the saved chime volume for this shell
103
+ VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
86
104
  VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
87
105
  VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
88
106
  `);
@@ -105,13 +123,33 @@ const VALUE_FLAGS = new Set([
105
123
 
106
124
  function parseArgs(argv) {
107
125
  const args = argv.slice(2);
108
- let genre = (process.env.VIBE_GENRE || "lofi").toLowerCase();
109
126
 
110
- let volume = normalizeVolume(process.env.VIBE_VOLUME, 0.40);
111
- let chimeVolume = normalizeVolume(process.env.VIBE_CHIME_VOLUME, null);
127
+ // Flag beats env var beats saved config beats the built-in default. The
128
+ // saved config is what makes "set my genre" have one answer instead of three
129
+ // - see loadConfig() in player.js - and it sits below the env var so a shell
130
+ // that exports one keeps winning, as it always did.
131
+ const saved = loadConfig();
132
+ // A setting saved for this directory sits between the environment and the
133
+ // global default: `--here` is a deliberate choice about one project, so it
134
+ // outranks the machine-wide one, and an export still outranks both.
135
+ const here = projectSettings(saved);
136
+ const setting = (key) => (here[key] !== undefined ? here[key] : saved[key]);
137
+
138
+ let genre = (process.env.VIBE_GENRE || setting("genre") || "lofi").toLowerCase();
139
+
140
+ let volume = normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(setting("volume"), 0.40));
141
+ let chimeVolume = normalizeVolume(process.env.VIBE_CHIME_VOLUME, normalizeVolume(setting("chimeVolume"), null));
112
142
 
113
143
  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;
144
+ let grace = !isNaN(envGrace) ? Math.max(0, envGrace)
145
+ : Number.isFinite(setting("grace")) ? Math.max(0, setting("grace"))
146
+ : DEFAULT_GRACE_PERIOD_MS;
147
+
148
+ // What the user typed on THIS command line, as opposed to what was inherited
149
+ // from the environment or the saved file. `vibe --genre jazz` with nothing
150
+ // after it is a request to change the default, and this is how run() tells
151
+ // that apart from a genre that merely happens to be in effect.
152
+ const typed = {};
115
153
 
116
154
  let noChime = false;
117
155
  let noHud = false;
@@ -124,6 +162,7 @@ function parseArgs(argv) {
124
162
  let hookAction = null;
125
163
  let mcp = false;
126
164
  let reactive = false;
165
+ let here_flag = false;
127
166
  let dryRun = false;
128
167
  let tools = null;
129
168
  let cmdArgs = [];
@@ -150,6 +189,7 @@ function parseArgs(argv) {
150
189
  if (arg === "-g" || arg === "--genre") {
151
190
  if (i + 1 < args.length) {
152
191
  genre = args[i + 1].toLowerCase();
192
+ typed.genre = genre;
153
193
  i += 2;
154
194
  continue;
155
195
  }
@@ -158,6 +198,7 @@ function parseArgs(argv) {
158
198
  if (arg === "-v" || arg === "--volume") {
159
199
  if (i + 1 < args.length) {
160
200
  volume = normalizeVolume(args[i + 1], volume);
201
+ typed.volume = volume;
161
202
  i += 2;
162
203
  continue;
163
204
  }
@@ -166,6 +207,7 @@ function parseArgs(argv) {
166
207
  if (arg === "-cv" || arg === "--chime-volume") {
167
208
  if (i + 1 < args.length) {
168
209
  chimeVolume = normalizeVolume(args[i + 1], chimeVolume);
210
+ typed.chimeVolume = chimeVolume;
169
211
  i += 2;
170
212
  continue;
171
213
  }
@@ -176,6 +218,7 @@ function parseArgs(argv) {
176
218
  const parsed = parseInt(args[i + 1], 10);
177
219
  if (!isNaN(parsed)) {
178
220
  grace = Math.max(0, parsed);
221
+ typed.grace = grace;
179
222
  }
180
223
  i += 2;
181
224
  continue;
@@ -239,6 +282,12 @@ function parseArgs(argv) {
239
282
  continue;
240
283
  }
241
284
 
285
+ if (arg === "--here") {
286
+ here_flag = true;
287
+ i += 1;
288
+ continue;
289
+ }
290
+
242
291
  if (arg === "--reactive") {
243
292
  reactive = true;
244
293
  i += 1;
@@ -259,18 +308,21 @@ function parseArgs(argv) {
259
308
 
260
309
  if (arg === "--whisper") {
261
310
  volume = 0.15;
311
+ typed.volume = volume;
262
312
  i += 1;
263
313
  continue;
264
314
  }
265
315
 
266
316
  if (arg === "--quiet") {
267
317
  volume = 0.25;
318
+ typed.volume = volume;
268
319
  i += 1;
269
320
  continue;
270
321
  }
271
322
 
272
323
  if (arg === "--loud") {
273
324
  volume = 0.75;
325
+ typed.volume = volume;
274
326
  i += 1;
275
327
  continue;
276
328
  }
@@ -313,11 +365,22 @@ function parseArgs(argv) {
313
365
  }
314
366
 
315
367
  if (!isKnownGenre(genre)) {
368
+ // A typo that would be SAVED is a different thing from a typo on a run:
369
+ // falling back to lofi here would quietly overwrite the default the user
370
+ // already had, on the strength of a misspelling. Refuse and change nothing.
371
+ if (typed.genre !== undefined && cmdArgs.length === 0) {
372
+ console.error(
373
+ `\x1b[31m[vibeaudio] Unknown genre '${genre}' — nothing saved.\x1b[0m\n` +
374
+ ` Available: ${AVAILABLE_GENRES.join(", ")}, random`
375
+ );
376
+ process.exit(1);
377
+ }
316
378
  console.error(
317
379
  `\x1b[33m[vibeaudio] Unknown genre '${genre}' — falling back to lofi.\x1b[0m\n` +
318
380
  ` Available: ${AVAILABLE_GENRES.join(", ")}, random`
319
381
  );
320
382
  genre = "lofi";
383
+ if (typed.genre) typed.genre = "lofi";
321
384
  }
322
385
 
323
386
  return {
@@ -338,6 +401,8 @@ function parseArgs(argv) {
338
401
  reactive,
339
402
  dryRun,
340
403
  tools,
404
+ typed,
405
+ hereOnly: here_flag,
341
406
  cmdArgs
342
407
  };
343
408
  }
@@ -382,9 +447,44 @@ function resolveTargets(explicit) {
382
447
  * The one install path. Both --install-hooks and the menu's hooks branch call
383
448
  * this, so the two entry points can't drift in what they write or report.
384
449
  */
385
- function installHookTargets(ids, genre, volume, reactive, dryRun = false) {
450
+ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed = {}) {
386
451
  const hooks = require("./hooks");
452
+
453
+ // Migrating off a pinned install must not change what is playing: those
454
+ // entries carry the settings the user has actually been hearing, while the
455
+ // config file may not exist yet - so an untyped reinstall inherits from the
456
+ // entries it is about to replace.
457
+ //
458
+ // A pin fills a gap and never overrides. Once a setting is saved, somebody
459
+ // chose it, and a legacy pin left in some other agent's config is the older
460
+ // fact of the two - without this, installing for every detected agent lets
461
+ // four untouched installs outvote the genre the user picked a minute ago.
462
+ const saved = loadConfig();
463
+ const inherited = [];
464
+ const gap = (key) => typed[key] === undefined && saved[key] === undefined;
465
+
466
+ if (gap("genre") || gap("volume")) {
467
+ const pinned = pinnedSettingsFor(ids);
468
+ if (gap("genre") && pinned.genre && pinned.genre !== genre) {
469
+ genre = pinned.genre;
470
+ inherited.push(`genre ${genre}`);
471
+ }
472
+ if (gap("volume") && pinned.volume !== null && pinned.volume !== volume) {
473
+ volume = pinned.volume;
474
+ inherited.push(`volume ${Math.round(volume * 100)}%`);
475
+ }
476
+ }
477
+
387
478
  if (dryRun) console.log(`\x1b[1mDry run\x1b[0m — nothing below is written.\n`);
479
+ if (inherited.length) {
480
+ console.log(`\x1b[90mKeeping your current ${inherited.join(" and ")} — pass --genre/--volume to change it.\x1b[0m`);
481
+ }
482
+
483
+ // The hook entries no longer carry the genre and volume, so this is where
484
+ // they live. Saved here rather than inside installHooks(), which stays a
485
+ // pure config edit that tests can point at a throwaway file - the same
486
+ // reason the slash command and stopDaemon() are driven from this layer.
487
+ if (!dryRun) saveConfig({ genre, volume: Math.round(volume * 100) });
388
488
 
389
489
  for (const id of ids) {
390
490
  const result = hooks.installHooks(genre, volume, null, { reactive, id, dryRun });
@@ -424,6 +524,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false) {
424
524
  // verified for each, so an open session picks this up on its next prompt.
425
525
  const live = ids.filter((id) => hooks.TARGETS[id].liveReload !== false).map((id) => hooks.TARGETS[id].name);
426
526
  if (live.length) console.log(` ${live.join(", ")}: takes effect on your next prompt - no restart needed.`);
527
+ console.log(` \x1b[90mChange the sound any time with: vibe --genre <name> --volume <n> — no reinstall.\x1b[0m`);
427
528
  if (ids.length > 1) {
428
529
  console.log(` \x1b[90mOne player is shared: whichever agent you prompt last owns the music.\x1b[0m`);
429
530
  }
@@ -515,9 +616,24 @@ function printStatus() {
515
616
  console.log(`\x1b[33m⏸ Muted by VIBE_DISABLE=${process.env.VIBE_DISABLE}\x1b[0m — automatic playback is off (--preview still works).\n`);
516
617
  }
517
618
 
619
+ // What will actually play, and which of the three possible sources decided
620
+ // it - the usual confusion is a saved default quietly beaten by an export.
621
+ const saved = loadConfig();
622
+ const here = projectSettings(saved);
623
+ const source = (env, key) =>
624
+ process.env[env] ? off(` from ${env}`)
625
+ : here[key] !== undefined ? off(" saved for this project")
626
+ : saved[key] !== undefined ? off(" saved")
627
+ : off(" default");
628
+
629
+ console.log(`\x1b[1mSound\x1b[0m`);
630
+ console.log(` genre ${on(genreNow())}${source("VIBE_GENRE", "genre")}`);
631
+ console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
632
+ console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
633
+
518
634
  // Audio backend
519
635
  const backend = detect();
520
- console.log(`\x1b[1mAudio\x1b[0m`);
636
+ console.log(`\n\x1b[1mAudio\x1b[0m`);
521
637
  console.log(backend
522
638
  ? ` player ${on(backend.cmd)}${backend.volume ? "" : off(" (no volume support — gain is baked into the file)")}`
523
639
  : ` player \x1b[31mnone found — VibeAudio runs silently\x1b[0m`);
@@ -539,28 +655,47 @@ function printStatus() {
539
655
  const why = detected.includes(id)
540
656
  ? `${off("not installed")} — run: vibe --install-hooks`
541
657
  : `${off("not installed")} ${off("(not found on this machine)")}`;
542
- console.log(` ${t.name.padEnd(13)}${why}`);
658
+ console.log(` ${t.name.padEnd(20)}${why}`);
543
659
  continue;
544
660
  }
545
661
 
546
662
  console.log(` ${t.name}`);
547
663
  for (const { event, command } of installed) {
664
+ // A baked genre means an install from before settings moved into the
665
+ // config file: worth showing, because it still overrides that file.
548
666
  const g = /--genre (\S+)/.exec(command);
549
667
  const vol = /--volume (\d+)/.exec(command);
550
- const extra = g ? ` ${g[1]} @ ${vol ? vol[1] : "?"}%${/--reactive/.test(command) ? ", reactive" : ""}` : "";
668
+ const parts = [];
669
+ if (g) parts.push(`pinned ${g[1]} @ ${vol ? vol[1] : "?"}%`);
670
+ if (/--reactive/.test(command)) parts.push("reactive");
671
+ const extra = parts.length ? ` ${parts.join(", ")}` : "";
551
672
  console.log(` ${on("✔")} ${extra ? event.padEnd(19) : event}${extra ? off(extra) : ""}`);
552
673
  }
674
+ if (readVibeHooks(t.file(), t).some(({ command }) => /--genre /.test(command))) {
675
+ console.log(` \x1b[33m↑ pinned by an older install — vibe --install-hooks makes it follow Sound above\x1b[0m`);
676
+ }
553
677
  }
554
678
 
555
679
  // Background player
556
680
  console.log(`\n\x1b[1mBackground player\x1b[0m`);
557
681
  const pid = readDaemonPid(hooks.PID_FILE);
682
+ const playing = hooks.daemonPlaying();
558
683
  if (pid === null) {
559
684
  console.log(` ${off("not running")}`);
560
- } else if (!hooks.isOurDaemon(pid)) {
685
+ } else if (!playing) {
561
686
  console.log(` ${off(`stale pid file (${pid} is not ours) — cleared on the next prompt`)}`);
562
687
  } else {
563
- console.log(` ${on(`running`)} pid ${pid}${off(" stop it with: vibe --stop")}`);
688
+ // What it was started with, which is not necessarily what is saved: a
689
+ // daemon keeps its settings until the next prompt replaces it, and
690
+ // "I changed the genre and nothing happened" is that gap.
691
+ const now = playing.genre
692
+ ? ` ${on(playing.genre)}${playing.volume === null ? "" : on(` @ ${playing.volume}%`)}${playing.reactive ? off(", reactive") : ""}`
693
+ : "";
694
+ console.log(` ${on("playing")}${now}${off(` pid ${pid}`)}`);
695
+ if (playing.genre && playing.genre !== genreNow()) {
696
+ console.log(` ${off(`your saved default is ${genreNow()} — this one keeps playing until the next prompt`)}`);
697
+ }
698
+ console.log(` ${off("stop it with: vibe --stop")}`);
564
699
  }
565
700
 
566
701
  // Which tools are actually here, and what each one can use
@@ -576,6 +711,24 @@ function printStatus() {
576
711
  console.log();
577
712
  }
578
713
 
714
+ /**
715
+ * The effective genre and volume, resolved the same way parseArgs does it, so
716
+ * --status reports what would actually play rather than a second opinion.
717
+ */
718
+ function settingNow(key) {
719
+ const config = loadConfig();
720
+ const here = projectSettings(config);
721
+ return here[key] !== undefined ? here[key] : config[key];
722
+ }
723
+
724
+ function genreNow() {
725
+ return (process.env.VIBE_GENRE || settingNow("genre") || "lofi").toLowerCase();
726
+ }
727
+
728
+ function volumeNow() {
729
+ return normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(settingNow("volume"), 0.40));
730
+ }
731
+
579
732
  function dirSize(dir) {
580
733
  try {
581
734
  let total = 0;
@@ -646,6 +799,107 @@ function detectAiTools(hooked = new Set()) {
646
799
  return CANDIDATES.filter((c) => isInstalled(c.cmd));
647
800
  }
648
801
 
802
+ /**
803
+ * `vibe --genre jazz` with no command after it. That used to open the launcher
804
+ * menu, which then asked for a genre and used its own answer - the most
805
+ * natural way to phrase the request did something else entirely, and the
806
+ * README had to warn about it. It saves the default instead.
807
+ */
808
+ function saveDefaults(typed, hereOnly = false) {
809
+ const patch = {};
810
+ if (typed.genre !== undefined) patch.genre = typed.genre;
811
+ if (typed.volume !== undefined) patch.volume = Math.round(typed.volume * 100);
812
+ if (typed.chimeVolume !== undefined) patch.chimeVolume = Math.round(typed.chimeVolume * 100);
813
+ if (typed.grace !== undefined) patch.grace = typed.grace;
814
+
815
+ const { CONFIG_FILE } = require("./player");
816
+ if (hereOnly) saveProjectConfig(patch);
817
+ else saveConfig(patch);
818
+
819
+ const label = {
820
+ genre: (v) => `genre ${v}`,
821
+ volume: (v) => `volume ${v}%`,
822
+ chimeVolume: (v) => `chime volume ${v}%`,
823
+ grace: (v) => `grace ${v}ms`
824
+ };
825
+ const changed = Object.keys(patch).map((k) => label[k](patch[k])).join(", ");
826
+ const where = hereOnly ? ` for ${process.cwd()}` : "";
827
+ console.log(`\x1b[32m✔ Saved: ${changed}${where}\x1b[0m \x1b[90m(${CONFIG_FILE})\x1b[0m`);
828
+ console.log(` Applies to the wrapper, your agent hooks and MCP — on the next prompt.`);
829
+ if (hereOnly) console.log(` \x1b[90mThis directory and everything under it. Drop --here to change the default everywhere.\x1b[0m`);
830
+
831
+ // Music already in the air is the one thing this does not reach, and
832
+ // silence about it reads as the setting having done nothing at all.
833
+ const playing = require("./hooks").daemonPlaying();
834
+ if (playing && patch.genre && playing.genre && playing.genre !== patch.genre) {
835
+ console.log(` \x1b[90m${playing.genre} is still playing — vibe --stop cuts it short.\x1b[0m`);
836
+ }
837
+
838
+ // An install from before the settings moved out of the hook command line
839
+ // still carries its own genre, and a flag beats this file. Saying nothing
840
+ // would leave the user with a saved default that audibly does nothing.
841
+ const pinned = pinnedHookSettings();
842
+ if (pinned.length) {
843
+ console.log(
844
+ `\n\x1b[33m ${pinned.join(", ")}: hooks installed by an older version pin their own settings.\x1b[0m\n` +
845
+ ` Run \x1b[1mvibe --install-hooks\x1b[0m once to have them follow this file instead.`
846
+ );
847
+ }
848
+ if (typed.genre !== undefined) console.log(`\n Audition it: vibe --preview ${typed.genre}`);
849
+ console.log();
850
+ }
851
+
852
+ /**
853
+ * Hook targets whose installed entries still carry a baked --genre/--volume.
854
+ */
855
+ /**
856
+ * What an install from before the config file pinned, for the first of `ids`
857
+ * that pinned anything.
858
+ *
859
+ * A reinstall is how a user moves off that older scheme, and it must not be
860
+ * the moment their music changes: the settings frozen into those entries are
861
+ * the ones they have actually been listening to, and the config file may hold
862
+ * nothing but an untouched default. Adopted only for what the user did not
863
+ * type on this command line - naming a genre still means that genre.
864
+ */
865
+ function pinnedSettingsFor(ids) {
866
+ const hooks = require("./hooks");
867
+ const genres = new Set();
868
+ const volumes = new Set();
869
+
870
+ for (const id of ids) {
871
+ const t = hooks.TARGETS[id];
872
+ if (!t) continue;
873
+ for (const { command } of readVibeHooks(t.file(), t)) {
874
+ const genre = /--genre (\S+)/.exec(command);
875
+ const volume = /--volume (\d+)/.exec(command);
876
+ if (genre && isKnownGenre(genre[1])) genres.add(genre[1]);
877
+ if (volume) volumes.add(normalizeVolume(volume[1], null));
878
+ }
879
+ }
880
+
881
+ // Only when they agree. The saved config is one value for the machine, so
882
+ // two agents pinned to different genres have no single answer to inherit -
883
+ // and taking whichever was found first would let an untouched install for
884
+ // some other tool silently overwrite the genre the user actually chose.
885
+ return {
886
+ genre: genres.size === 1 ? [...genres][0] : null,
887
+ volume: volumes.size === 1 ? [...volumes][0] : null
888
+ };
889
+ }
890
+
891
+ function pinnedHookSettings() {
892
+ const hooks = require("./hooks");
893
+ const names = [];
894
+ for (const id of Object.keys(hooks.TARGETS)) {
895
+ const t = hooks.TARGETS[id];
896
+ if (readVibeHooks(t.file(), t).some(({ command }) => /--genre |--volume /.test(command))) {
897
+ names.push(t.name);
898
+ }
899
+ }
900
+ return names;
901
+ }
902
+
649
903
  function signalExitCode(signal) {
650
904
  return 128 + (os.constants.signals[signal] || 0);
651
905
  }
@@ -670,7 +924,7 @@ function previewGenre(genre, volume) {
670
924
  spawnSync(backend.cmd, backend.args(audioFile, volume), { stdio: "ignore" });
671
925
  }
672
926
 
673
- function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun }) {
927
+ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed = {} }) {
674
928
  const hooks = require("./hooks");
675
929
 
676
930
  switch (action) {
@@ -693,7 +947,8 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
693
947
  outcome: hooks.outcomeFromPayload(raw),
694
948
  volume,
695
949
  chimeVolume,
696
- noChime
950
+ noChime,
951
+ raw
697
952
  });
698
953
  });
699
954
  return;
@@ -714,7 +969,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
714
969
  return;
715
970
 
716
971
  case "install-hooks":
717
- return installHookTargets(resolveTargets(tools), genre, volume, reactive, dryRun);
972
+ return installHookTargets(resolveTargets(tools), genre, volume, reactive, dryRun, typed);
718
973
 
719
974
  case "uninstall-hooks":
720
975
  return uninstallHookTargets();
@@ -876,6 +1131,8 @@ async function run() {
876
1131
  reactive,
877
1132
  dryRun,
878
1133
  tools,
1134
+ typed,
1135
+ hereOnly,
879
1136
  cmdArgs
880
1137
  } = parseArgs(process.argv);
881
1138
 
@@ -889,7 +1146,7 @@ async function run() {
889
1146
 
890
1147
  if (hookAction) {
891
1148
  try {
892
- return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun });
1149
+ return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed });
893
1150
  } catch (e) {
894
1151
  // Settings problems are the user's to fix — report them, don't stack-trace.
895
1152
  console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
@@ -950,6 +1207,15 @@ async function run() {
950
1207
  return previewGenre(preview, volume);
951
1208
  }
952
1209
 
1210
+ // Settings typed with no command to run: save them, don't open the launcher.
1211
+ if (cmdArgs.length === 0 && Object.keys(typed).length > 0) {
1212
+ return saveDefaults(typed, hereOnly);
1213
+ }
1214
+
1215
+ if (hereOnly) {
1216
+ console.error("\x1b[33m[vibeaudio] --here only applies when saving a setting; ignoring it.\x1b[0m");
1217
+ }
1218
+
953
1219
  if (cmdArgs.length === 0) {
954
1220
  if (!process.stdin.isTTY) {
955
1221
  printHelp();
@@ -978,7 +1244,8 @@ async function run() {
978
1244
  // Either way, say so and still launch the tool the user asked for -
979
1245
  // they came here to start an agent, not to configure one.
980
1246
  try {
981
- installHookTargets([selection.hookTarget], selection.genre, chosenVol, selection.reactive);
1247
+ installHookTargets([selection.hookTarget], selection.genre, chosenVol, selection.reactive, false,
1248
+ { genre: selection.genre, volume: chosenVol });
982
1249
  } catch (err) {
983
1250
  console.error(`\x1b[31m[vibeaudio] ${err.message}\x1b[0m`);
984
1251
  }