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/LICENSE +21 -0
- package/README.md +601 -0
- package/bin/vibeaudio.js +16 -0
- package/package.json +51 -0
- package/src/cli.js +993 -0
- package/src/hooks.js +980 -0
- package/src/hud.js +84 -0
- package/src/interactive.js +338 -0
- package/src/mcp.js +292 -0
- package/src/player.js +666 -0
- package/src/synth/chime.js +137 -0
- package/src/synth/chiptune.js +132 -0
- package/src/synth/drone.js +133 -0
- package/src/synth/electronic.js +133 -0
- package/src/synth/generator.js +179 -0
- package/src/synth/jazz.js +178 -0
- package/src/synth/lofi.js +148 -0
- package/src/synth/piano.js +158 -0
- package/src/synth/synthwave.js +170 -0
- package/src/synth/zen.js +148 -0
package/src/hud.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII Waveform HUD & Terminal Tab Animation
|
|
3
|
+
* Zero dependencies - Pure ANSI escape codes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const WAVES = [
|
|
7
|
+
" ▂▃▅▆▅▃ ",
|
|
8
|
+
"▂▃▅▆▇▆▅▂",
|
|
9
|
+
"▃▅▆▇█▇▆▃",
|
|
10
|
+
"▅▆▇███▇▅",
|
|
11
|
+
"▆▇█▇▆▅▃▂",
|
|
12
|
+
"▇█▇▆▅▃▂ "
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function formatTime(ms) {
|
|
16
|
+
const totalSec = Math.floor(ms / 1000);
|
|
17
|
+
const m = Math.floor(totalSec / 60);
|
|
18
|
+
const s = totalSec % 60;
|
|
19
|
+
return `${m}:${s < 10 ? "0" : ""}${s}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class TerminalHud {
|
|
23
|
+
constructor(genre = "lofi") {
|
|
24
|
+
this.genre = genre;
|
|
25
|
+
this.timer = null;
|
|
26
|
+
this.startTime = 0;
|
|
27
|
+
this.frameIdx = 0;
|
|
28
|
+
this.started = false;
|
|
29
|
+
this.stopped = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
start() {
|
|
33
|
+
this.startTime = Date.now();
|
|
34
|
+
this.started = true;
|
|
35
|
+
|
|
36
|
+
// The wrapped tool may own the screen by now (full-screen TUIs like claude
|
|
37
|
+
// or aider), so the title bar is the only safe place to draw.
|
|
38
|
+
this.timer = setInterval(() => {
|
|
39
|
+
this.frameIdx = (this.frameIdx + 1) % WAVES.length;
|
|
40
|
+
const wave = WAVES[this.frameIdx];
|
|
41
|
+
const timeStr = formatTime(Date.now() - this.startTime);
|
|
42
|
+
|
|
43
|
+
// Set terminal window & tab title (works in Mac Terminal, iTerm2, VS Code, Warp)
|
|
44
|
+
if (process.stdout.isTTY) {
|
|
45
|
+
process.stdout.write(`\x1b]0;[ ♫ ${wave} ${this.genre} (${timeStr}) ]\x07`);
|
|
46
|
+
}
|
|
47
|
+
}, 120);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// `chimed` is what actually played, not what the outcome was: --no-chime and
|
|
51
|
+
// a mute both leave the sound off, and announcing a chime nobody heard sends
|
|
52
|
+
// the user looking for a broken speaker.
|
|
53
|
+
stop({ outcome = "success", code = 0, interrupted = false, chimed = true } = {}) {
|
|
54
|
+
if (this.timer) {
|
|
55
|
+
clearInterval(this.timer);
|
|
56
|
+
this.timer = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Nothing was ever drawn (fast command), or cleanup ran twice.
|
|
60
|
+
if (!this.started || this.stopped) return;
|
|
61
|
+
this.stopped = true;
|
|
62
|
+
|
|
63
|
+
const timeStr = ((Date.now() - this.startTime) / 1000).toFixed(1);
|
|
64
|
+
|
|
65
|
+
// Reset terminal window/tab title
|
|
66
|
+
if (process.stdout.isTTY) {
|
|
67
|
+
process.stdout.write(`\x1b]0;\x07`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!process.stdout.isTTY) return;
|
|
71
|
+
|
|
72
|
+
if (interrupted) {
|
|
73
|
+
process.stdout.write(`\n\x1b[90m■ [VibeAudio] Interrupted after ${timeStr}s\x1b[0m\n`);
|
|
74
|
+
} else if (code === 0) {
|
|
75
|
+
const tail = chimed ? " • Ascending resolution chime" : "";
|
|
76
|
+
process.stdout.write(`\n\x1b[32m✔ [VibeAudio] Done in ${timeStr}s${tail}\x1b[0m\n`);
|
|
77
|
+
} else {
|
|
78
|
+
const tail = chimed ? " • Descending minor tone" : "";
|
|
79
|
+
process.stdout.write(`\n\x1b[33m✖ [VibeAudio] Command exited with code ${code} in ${timeStr}s${tail}\x1b[0m\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { TerminalHud };
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Terminal Menu for VibeAudio
|
|
3
|
+
* Zero dependencies - Pure Node.js ANSI and Readline
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { execSync } = require("child_process");
|
|
7
|
+
const readline = require("readline");
|
|
8
|
+
|
|
9
|
+
// Windows has no `which`; without this every tool shows as missing there.
|
|
10
|
+
const LOOKUP_CMD = process.platform === "win32" ? "where" : "which";
|
|
11
|
+
|
|
12
|
+
function isInstalled(cmd) {
|
|
13
|
+
try {
|
|
14
|
+
execSync(`${LOOKUP_CMD} ${cmd}`, { stdio: "ignore" });
|
|
15
|
+
return true;
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Convenience only - the wrapper runs any command, and "Custom command..."
|
|
22
|
+
// is the escape hatch for anything not listed. Entries are sorted so what is
|
|
23
|
+
// actually installed floats to the top, which is what lets this list grow
|
|
24
|
+
// without turning into a wall of things the user does not have.
|
|
25
|
+
// `hookTarget` is the id in hooks.js TARGETS, and it is the only thing that
|
|
26
|
+
// decides whether this tool gets the delivery and reactive questions. It is
|
|
27
|
+
// not the same string as `check`: Cursor's CLI is `cursor-agent` while its
|
|
28
|
+
// target id is `cursor`, which is exactly the kind of mismatch a second
|
|
29
|
+
// hand-kept list gets wrong.
|
|
30
|
+
const AI_TOOLS = [
|
|
31
|
+
{ name: "Claude Code", cmd: ["claude"], check: "claude", hookTarget: "claude" },
|
|
32
|
+
{ name: "Gemini CLI", cmd: ["gemini"], check: "gemini", hookTarget: "gemini" },
|
|
33
|
+
{ name: "Codex CLI", cmd: ["codex"], check: "codex", hookTarget: "codex" },
|
|
34
|
+
{ name: "Grok CLI", cmd: ["grok"], check: "grok", hookTarget: "grok" },
|
|
35
|
+
{ name: "Cursor CLI", cmd: ["cursor-agent"], check: "cursor-agent", hookTarget: "cursor" },
|
|
36
|
+
{ name: "GitHub Copilot CLI", cmd: ["copilot"], check: "copilot", hookTarget: "copilot" },
|
|
37
|
+
{ name: "Qwen Code", cmd: ["qwen"], check: "qwen", hookTarget: "qwen" },
|
|
38
|
+
{ name: "Aider", cmd: ["aider"], check: "aider" },
|
|
39
|
+
{ name: "Ollama (Llama 3)", cmd: ["ollama", "run", "llama3"], check: "ollama" },
|
|
40
|
+
{ name: "Custom command...", cmd: null }
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const GENRES = [
|
|
44
|
+
{ name: "☕ Lo-Fi Focus", desc: "Warm Rhodes electric piano & Kalimba drops", id: "lofi" },
|
|
45
|
+
{ name: "🌌 Chill Synthwave", desc: "'80s analog chorused pads & pulsing bass", id: "synthwave" },
|
|
46
|
+
{ name: "🕹️ Cozy 8-Bit", desc: "Filtered retro chiptune arpeggios", id: "8bit" },
|
|
47
|
+
{ name: "⚡ Melodic Electronic", desc: "Downtempo resonant plucks & tech pulse", id: "electronic" },
|
|
48
|
+
{ name: "🎷 Midnight Jazz", desc: "ii-V-I piano chords & walking upright bass", id: "jazz" },
|
|
49
|
+
{ name: "🎋 Zen Ambient", desc: "Meditative singing bowls & floating celestial pads", id: "zen" },
|
|
50
|
+
{ name: "🎹 Sparse Piano", desc: "Single struck notes & long silences — Satie-ish", id: "piano" },
|
|
51
|
+
{ name: "🌫️ Deep Drone", desc: "Held tone & filtered noise — no melody at all", id: "drone" },
|
|
52
|
+
{ name: "🎲 Shuffle / Random", desc: "Picks a surprise vibe each time", id: "random" }
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function selectMenu(title, items, renderItem) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
let selected = 0;
|
|
58
|
+
const stdin = process.stdin;
|
|
59
|
+
const stdout = process.stdout;
|
|
60
|
+
|
|
61
|
+
if (!stdin.isTTY) {
|
|
62
|
+
// Fallback if not an interactive terminal
|
|
63
|
+
resolve(items[0]);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stdin.setRawMode(true);
|
|
68
|
+
stdin.resume();
|
|
69
|
+
stdin.setEncoding("utf8");
|
|
70
|
+
|
|
71
|
+
function render(firstTime = false) {
|
|
72
|
+
if (!firstTime) {
|
|
73
|
+
// Move cursor up to overwrite previous menu
|
|
74
|
+
stdout.write(`\u001b[${items.length + 1}A`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
stdout.write(`\r\x1b[1m\x1b[36m${title}\x1b[0m\x1b[K\n`);
|
|
78
|
+
for (let i = 0; i < items.length; i++) {
|
|
79
|
+
const isSel = i === selected;
|
|
80
|
+
const prefix = isSel ? "\x1b[32m❯ \x1b[1m" : " \x1b[2m";
|
|
81
|
+
const suffix = "\x1b[0m\x1b[K\n";
|
|
82
|
+
stdout.write(`${prefix}${renderItem(items[i], i + 1, isSel)}${suffix}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(true);
|
|
87
|
+
|
|
88
|
+
function onData(key) {
|
|
89
|
+
if (key === "\u0003") {
|
|
90
|
+
// Ctrl+C
|
|
91
|
+
cleanup();
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (key === "\u001b[A") {
|
|
96
|
+
// Up arrow
|
|
97
|
+
selected = (selected - 1 + items.length) % items.length;
|
|
98
|
+
render();
|
|
99
|
+
} else if (key === "\u001b[B") {
|
|
100
|
+
// Down arrow
|
|
101
|
+
selected = (selected + 1) % items.length;
|
|
102
|
+
render();
|
|
103
|
+
} else if (key === "\r" || key === "\n") {
|
|
104
|
+
// Enter
|
|
105
|
+
cleanup();
|
|
106
|
+
stdout.write("\n");
|
|
107
|
+
resolve(items[selected]);
|
|
108
|
+
} else {
|
|
109
|
+
// Direct number selection 1..9
|
|
110
|
+
const num = parseInt(key, 10);
|
|
111
|
+
if (!isNaN(num) && num >= 1 && num <= items.length) {
|
|
112
|
+
selected = num - 1;
|
|
113
|
+
cleanup();
|
|
114
|
+
render();
|
|
115
|
+
stdout.write("\n");
|
|
116
|
+
resolve(items[selected]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function cleanup() {
|
|
122
|
+
stdin.removeListener("data", onData);
|
|
123
|
+
stdin.setRawMode(false);
|
|
124
|
+
stdin.pause();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
stdin.on("data", onData);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Splits a command line into argv, keeping quoted arguments intact so
|
|
133
|
+
* `claude --append-system-prompt "be brief"` stays three tokens, not four.
|
|
134
|
+
* Tokens are passed to spawn() as argv, never through a shell.
|
|
135
|
+
*/
|
|
136
|
+
function tokenizeCommand(input) {
|
|
137
|
+
const tokens = [];
|
|
138
|
+
let current = "";
|
|
139
|
+
let started = false;
|
|
140
|
+
let quote = null;
|
|
141
|
+
|
|
142
|
+
for (const ch of input.trim()) {
|
|
143
|
+
if (quote) {
|
|
144
|
+
if (ch === quote) quote = null;
|
|
145
|
+
else current += ch;
|
|
146
|
+
} else if (ch === '"' || ch === "'") {
|
|
147
|
+
quote = ch;
|
|
148
|
+
started = true;
|
|
149
|
+
} else if (/\s/.test(ch)) {
|
|
150
|
+
if (started) {
|
|
151
|
+
tokens.push(current);
|
|
152
|
+
current = "";
|
|
153
|
+
started = false;
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
current += ch;
|
|
157
|
+
started = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (started) tokens.push(current);
|
|
162
|
+
return tokens;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function promptCustomCommand() {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const rl = readline.createInterface({
|
|
168
|
+
input: process.stdin,
|
|
169
|
+
output: process.stdout
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
rl.question("\x1b[1mEnter command to wrap with music:\x1b[0m ", (answer) => {
|
|
173
|
+
rl.close();
|
|
174
|
+
const parts = tokenizeCommand(answer);
|
|
175
|
+
resolve(parts.length > 0 ? parts : ["echo", "No command specified"]);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const VOLUMES = [
|
|
181
|
+
{ name: "☕ Normal (40%)", desc: "Balanced focus background [Default]", vol: 0.40 },
|
|
182
|
+
{ name: "🤫 Quiet (25%)", desc: "Discreet focus / open office", vol: 0.25 },
|
|
183
|
+
{ name: "🌙 Whisper (15%)", desc: "Ultra-gentle / headphones / late night", vol: 0.15 },
|
|
184
|
+
{ name: "📢 Loud (75%)", desc: "Audible across the room", vol: 0.75 }
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Which target's hooks this menu entry can install, or null for the tools that
|
|
189
|
+
* only get the wrapper - and so have no delivery choice worth asking about.
|
|
190
|
+
*
|
|
191
|
+
* Read out of hooks.js TARGETS rather than listed again here. A hand-kept copy
|
|
192
|
+
* is how Grok became a fully supported hook target - its own file, its own
|
|
193
|
+
* event names, a row in the README - that this menu could never install for:
|
|
194
|
+
* picking it offered the wrapper and nothing else. Deriving it means adding a
|
|
195
|
+
* target wires up the menu too, or fails loudly on a typo'd id.
|
|
196
|
+
*
|
|
197
|
+
* Required lazily: hooks.js reaches back into this module for isInstalled().
|
|
198
|
+
*/
|
|
199
|
+
function hookTargetOf(tool) {
|
|
200
|
+
if (!tool || !tool.hookTarget) return null;
|
|
201
|
+
const { TARGETS } = require("./hooks");
|
|
202
|
+
if (!TARGETS[tool.hookTarget]) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`launcher entry '${tool.name}' names hook target '${tool.hookTarget}', ` +
|
|
205
|
+
`which is not one of: ${Object.keys(TARGETS).join(", ")}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return tool.hookTarget;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const DELIVERY_FRESH = [
|
|
212
|
+
{
|
|
213
|
+
id: "hooks",
|
|
214
|
+
name: "Agent hooks",
|
|
215
|
+
desc: "Music follows the agent's thinking. Set once, no wrapper needed"
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "wrapper",
|
|
219
|
+
name: "This session only",
|
|
220
|
+
desc: "Music plays while the process lives - fine for one-shot commands"
|
|
221
|
+
}
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
// With hooks already installed, the wrapper plays nothing for an interactive
|
|
225
|
+
// claude - so offering "this session only" would be offering silence. The
|
|
226
|
+
// honest second option is to just launch, and to stop asking for a genre and
|
|
227
|
+
// volume the hooks are going to ignore.
|
|
228
|
+
const DELIVERY_INSTALLED = [
|
|
229
|
+
{
|
|
230
|
+
id: "launch",
|
|
231
|
+
name: "Just launch it",
|
|
232
|
+
desc: "Your installed hooks already handle the music"
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "hooks",
|
|
236
|
+
name: "Reconfigure the hooks",
|
|
237
|
+
desc: "Pick a new genre, volume or reactive setting"
|
|
238
|
+
}
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const REACTIVE = [
|
|
242
|
+
{ id: false, name: "Steady (recommended)", desc: "Intensity follows elapsed time, and stays ignorable" },
|
|
243
|
+
{ id: true, name: "Reactive", desc: "Intensity follows the tool in use - noticeable, by design" }
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `hooksInstalledFor` is asked about the tool the user picked, not about
|
|
248
|
+
* Claude: the caller cannot know which tool that is until this menu runs, and
|
|
249
|
+
* answering for Claude every time told a Grok user with Grok hooks already
|
|
250
|
+
* installed that they had none.
|
|
251
|
+
*/
|
|
252
|
+
async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
|
|
253
|
+
console.log(`\n\x1b[1m\x1b[35m🎧 VibeAudio — Interactive AI Launcher\x1b[0m\n`);
|
|
254
|
+
|
|
255
|
+
// 1. Select AI Tool
|
|
256
|
+
const toolsWithStatus = AI_TOOLS.map((t) => ({
|
|
257
|
+
...t,
|
|
258
|
+
installed: t.check ? isInstalled(t.check) : null
|
|
259
|
+
}));
|
|
260
|
+
|
|
261
|
+
// Installed tools first, "Custom command..." last, each group keeping the
|
|
262
|
+
// order above. A stable sort is required for that, which Node guarantees.
|
|
263
|
+
const rank = (t) => (t.installed === true ? 0 : t.installed === false ? 1 : 2);
|
|
264
|
+
toolsWithStatus.sort((a, b) => rank(a) - rank(b));
|
|
265
|
+
|
|
266
|
+
const selectedTool = await selectMenu(
|
|
267
|
+
"Which AI companion would you like to launch?",
|
|
268
|
+
toolsWithStatus,
|
|
269
|
+
(item, num) => {
|
|
270
|
+
const status = item.installed === true ? " \x1b[32m[✓ installed]\x1b[0m" : item.installed === false ? " \x1b[90m(not found in PATH)\x1b[0m" : "";
|
|
271
|
+
return `${num}. ${item.name}${status}`;
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
let finalCmd = selectedTool.cmd;
|
|
276
|
+
if (!finalCmd) {
|
|
277
|
+
finalCmd = await promptCustomCommand();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 2. How should the music be delivered?
|
|
281
|
+
// Without this the menu asks for a genre and volume that installed hooks
|
|
282
|
+
// then ignore - three questions asked, one honoured.
|
|
283
|
+
let delivery = "wrapper";
|
|
284
|
+
const hookTarget = hookTargetOf(selectedTool);
|
|
285
|
+
if (hookTarget) {
|
|
286
|
+
const installed = hooksInstalledFor(hookTarget);
|
|
287
|
+
const chosen = await selectMenu(
|
|
288
|
+
installed ? "Hooks are already installed. What now?" : "How should the music run?",
|
|
289
|
+
installed ? DELIVERY_INSTALLED : DELIVERY_FRESH,
|
|
290
|
+
(item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
|
|
291
|
+
);
|
|
292
|
+
delivery = chosen.id;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Nothing downstream reads a genre or volume on this path, so don't ask for one.
|
|
296
|
+
if (delivery === "launch") {
|
|
297
|
+
return { cmd: finalCmd, installHooks: false, reactive: false };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 3. Select Music Genre
|
|
301
|
+
const selectedGenre = await selectMenu(
|
|
302
|
+
"Choose your sound vibe:",
|
|
303
|
+
GENRES,
|
|
304
|
+
(item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// 4. Select Volume Preset
|
|
308
|
+
const selectedVolume = await selectMenu(
|
|
309
|
+
"Choose your volume level:",
|
|
310
|
+
VOLUMES,
|
|
311
|
+
(item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// 5. Reactive is a hooks-only feature, so only offer it on that branch -
|
|
315
|
+
// a control that does nothing is worse than no control.
|
|
316
|
+
let reactive = false;
|
|
317
|
+
if (delivery === "hooks") {
|
|
318
|
+
const chosen = await selectMenu(
|
|
319
|
+
"Should the music react to what the agent is doing?",
|
|
320
|
+
REACTIVE,
|
|
321
|
+
(item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
|
|
322
|
+
);
|
|
323
|
+
reactive = chosen.id;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
cmd: finalCmd,
|
|
328
|
+
genre: selectedGenre.id,
|
|
329
|
+
volume: selectedVolume.vol,
|
|
330
|
+
installHooks: delivery === "hooks",
|
|
331
|
+
// The menu asked about one tool, so it installs for that one - auto-detect
|
|
332
|
+
// belongs to the bare --install-hooks flag, where nothing was chosen.
|
|
333
|
+
hookTarget: delivery === "hooks" ? hookTarget : null,
|
|
334
|
+
reactive
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, AI_TOOLS };
|