@settinghead/voxlert 0.3.5
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 +353 -0
- package/assets/cortana.png +0 -0
- package/assets/deckard-cain.png +0 -0
- package/assets/demo-thumbnail.png +0 -0
- package/assets/glados.png +0 -0
- package/assets/hl-hev-suit.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/red-alert-eva.png +0 -0
- package/assets/sc1-adjutant.gif +0 -0
- package/assets/sc1-kerrigan.gif +0 -0
- package/assets/sc1-protoss-advisor.jpg +0 -0
- package/assets/sc2-adjutant.jpg +0 -0
- package/assets/sc2-kerrigan.jpg +0 -0
- package/assets/ss1-shodan.png +0 -0
- package/config.default.json +35 -0
- package/openclaw-plugin/index.ts +100 -0
- package/openclaw-plugin/openclaw.plugin.json +21 -0
- package/package.json +51 -0
- package/packs/hl-hev-suit/pack.json +72 -0
- package/packs/hl-hev-suit/voice.wav +0 -0
- package/packs/red-alert-eva/pack.json +73 -0
- package/packs/red-alert-eva/voice.wav +0 -0
- package/packs/sc1-adjutant/pack.json +31 -0
- package/packs/sc1-adjutant/voice.wav +0 -0
- package/packs/sc1-kerrigan/pack.json +69 -0
- package/packs/sc1-kerrigan/voice.wav +0 -0
- package/packs/sc1-protoss-advisor/pack.json +70 -0
- package/packs/sc1-protoss-advisor/voice.wav +0 -0
- package/packs/sc2-adjutant/pack.json +14 -0
- package/packs/sc2-adjutant/voice.wav +0 -0
- package/packs/sc2-kerrigan/pack.json +69 -0
- package/packs/sc2-kerrigan/voice.wav +0 -0
- package/packs/sc2-protoss-advisor/pack.json +70 -0
- package/packs/sc2-protoss-advisor/voice.wav +0 -0
- package/packs/ss1-shodan/pack.json +69 -0
- package/packs/ss1-shodan/voice.wav +0 -0
- package/skills/voxlert-config/SKILL.md +44 -0
- package/src/activity-log.js +58 -0
- package/src/audio.js +381 -0
- package/src/cli.js +86 -0
- package/src/codex-config.js +149 -0
- package/src/commands/codex-notify.js +70 -0
- package/src/commands/config.js +141 -0
- package/src/commands/cost.js +20 -0
- package/src/commands/cursor-hook.js +52 -0
- package/src/commands/help.js +25 -0
- package/src/commands/hook-utils.js +73 -0
- package/src/commands/hook.js +27 -0
- package/src/commands/index.js +45 -0
- package/src/commands/log.js +92 -0
- package/src/commands/notification.js +50 -0
- package/src/commands/pack-helpers.js +157 -0
- package/src/commands/pack.js +25 -0
- package/src/commands/setup.js +13 -0
- package/src/commands/test.js +14 -0
- package/src/commands/uninstall.js +60 -0
- package/src/commands/version.js +12 -0
- package/src/commands/voice.js +14 -0
- package/src/commands/volume.js +38 -0
- package/src/config.js +230 -0
- package/src/cost.js +124 -0
- package/src/cursor-hooks.js +93 -0
- package/src/formats.js +55 -0
- package/src/hooks.js +129 -0
- package/src/llm.js +237 -0
- package/src/overlay.js +212 -0
- package/src/overlay.jxa +186 -0
- package/src/pack-registry.js +28 -0
- package/src/packs.js +182 -0
- package/src/paths.js +39 -0
- package/src/postinstall.js +13 -0
- package/src/providers.js +129 -0
- package/src/setup-ui.js +177 -0
- package/src/setup.js +504 -0
- package/src/tts-test.js +243 -0
- package/src/upgrade-check.js +137 -0
- package/src/voxlert.js +200 -0
- package/voxlert.sh +4 -0
package/src/setup-ui.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { getProvider } from "./providers.js";
|
|
2
|
+
|
|
3
|
+
const ANSI = {
|
|
4
|
+
reset: "\x1b[0m",
|
|
5
|
+
bold: "\x1b[1m",
|
|
6
|
+
dim: "\x1b[2m",
|
|
7
|
+
cyan: "\x1b[36m",
|
|
8
|
+
green: "\x1b[32m",
|
|
9
|
+
yellow: "\x1b[33m",
|
|
10
|
+
hideCursor: "\x1b[?25l",
|
|
11
|
+
showCursor: "\x1b[?25h",
|
|
12
|
+
clear: "\x1b[2J",
|
|
13
|
+
home: "\x1b[H",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const LOGO_LINES = [
|
|
17
|
+
"██╗ ██╗ ██████╗ ██╗ ██████╗███████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗",
|
|
18
|
+
"██║ ██║██╔═══██╗██║██╔════╝██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝",
|
|
19
|
+
"██║ ██║██║ ██║██║██║ █████╗ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ",
|
|
20
|
+
"╚██╗ ██╔╝██║ ██║██║██║ ██╔══╝ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ",
|
|
21
|
+
" ╚████╔╝ ╚██████╔╝██║╚██████╗███████╗██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗",
|
|
22
|
+
" ╚═══╝ ╚═════╝ ╚═╝ ╚═════╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function color(text, code) {
|
|
26
|
+
return `${code}${text}${ANSI.reset}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function useFancyUi() {
|
|
30
|
+
return Boolean(process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rgb(r, g, b) {
|
|
34
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function interpolate(a, b, t) {
|
|
38
|
+
return Math.round(a + (b - a) * t);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const LOGO_GRADIENT_STOPS = [
|
|
42
|
+
[88, 196, 255],
|
|
43
|
+
[116, 140, 255],
|
|
44
|
+
[178, 92, 255],
|
|
45
|
+
[255, 64, 196],
|
|
46
|
+
[255, 82, 120],
|
|
47
|
+
[255, 64, 196],
|
|
48
|
+
[178, 92, 255],
|
|
49
|
+
[116, 140, 255],
|
|
50
|
+
[88, 196, 255],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function sampleLogoGradient(t) {
|
|
54
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
55
|
+
const segments = LOGO_GRADIENT_STOPS.length - 1;
|
|
56
|
+
const scaled = clamped * segments;
|
|
57
|
+
const index = Math.min(Math.floor(scaled), segments - 1);
|
|
58
|
+
const local = scaled - index;
|
|
59
|
+
const eased = 0.5 - (Math.cos(local * Math.PI) / 2);
|
|
60
|
+
const [r1, g1, b1] = LOGO_GRADIENT_STOPS[index];
|
|
61
|
+
const [r2, g2, b2] = LOGO_GRADIENT_STOPS[index + 1];
|
|
62
|
+
return [
|
|
63
|
+
interpolate(r1, r2, eased),
|
|
64
|
+
interpolate(g1, g2, eased),
|
|
65
|
+
interpolate(b1, b2, eased),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function animatedLogoLine(text, phase = 0, shimmerIndex = -1) {
|
|
70
|
+
if (!useFancyUi()) return text;
|
|
71
|
+
const chars = [...text];
|
|
72
|
+
const last = Math.max(chars.length - 1, 1);
|
|
73
|
+
return chars.map((ch, index) => {
|
|
74
|
+
if (ch === " ") return ch;
|
|
75
|
+
const raw = (index / last) + phase;
|
|
76
|
+
const wrapped = raw - Math.floor(raw);
|
|
77
|
+
let [r, g, b] = sampleLogoGradient(wrapped);
|
|
78
|
+
if (Math.abs(index - shimmerIndex) <= 1) {
|
|
79
|
+
r = Math.min(255, r + 70);
|
|
80
|
+
g = Math.min(255, g + 70);
|
|
81
|
+
b = Math.min(255, b + 70);
|
|
82
|
+
}
|
|
83
|
+
return `${rgb(r, g, b)}${ANSI.bold}${ch}${ANSI.reset}`;
|
|
84
|
+
}).join("") + ANSI.reset;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function centerLine(text, width = 92) {
|
|
88
|
+
const padding = Math.max(0, Math.floor((width - text.length) / 2));
|
|
89
|
+
return `${" ".repeat(padding)}${text}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sleep(ms) {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatCurrentConfig(config, installedPlatforms) {
|
|
97
|
+
const provider = getProvider(config.llm_backend || "openrouter");
|
|
98
|
+
const providerLabel = config.llm_api_key
|
|
99
|
+
? `${provider ? provider.name : (config.llm_backend || "openrouter")} (${config.llm_model || provider?.defaultModel || "default"})`
|
|
100
|
+
: "Fallback only";
|
|
101
|
+
const ttsLabel = config.tts_backend || "chatterbox";
|
|
102
|
+
const voiceLabel = config.active_pack || "sc2-adjutant";
|
|
103
|
+
const platforms = installedPlatforms.filter(Boolean);
|
|
104
|
+
return [
|
|
105
|
+
`${color("Current", ANSI.dim)} ${providerLabel}`,
|
|
106
|
+
`${color("Voice", ANSI.dim)} ${voiceLabel}`,
|
|
107
|
+
`${color("TTS", ANSI.dim)} ${ttsLabel}`,
|
|
108
|
+
`${color("Hooks", ANSI.dim)} ${platforms.length > 0 ? platforms.join(", ") : "None"}`,
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderLogoFrame(config, installedPlatforms, shimmerStep = -1) {
|
|
113
|
+
const current = formatCurrentConfig(config, installedPlatforms).map((line) => ` ${line}`);
|
|
114
|
+
const rule = color("┈".repeat(92), ANSI.dim);
|
|
115
|
+
const glow = color("SYNTHETIC VOICE NOTIFICATIONS FOR AGENT WORKFLOWS", ANSI.cyan);
|
|
116
|
+
const logo = LOGO_LINES.map((line, index) => {
|
|
117
|
+
const shimmerIndex = shimmerStep >= 0 ? shimmerStep - index * 3 : -1;
|
|
118
|
+
const phase = shimmerStep >= 0 ? shimmerStep * 0.015 + index * 0.02 : index * 0.02;
|
|
119
|
+
return centerLine(animatedLogoLine(line, phase, shimmerIndex), 92);
|
|
120
|
+
});
|
|
121
|
+
return [
|
|
122
|
+
"",
|
|
123
|
+
rule,
|
|
124
|
+
...logo,
|
|
125
|
+
centerLine(glow, 92),
|
|
126
|
+
rule,
|
|
127
|
+
"",
|
|
128
|
+
...current,
|
|
129
|
+
"",
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function printSetupHeader(config, installedPlatforms) {
|
|
134
|
+
if (!useFancyUi()) {
|
|
135
|
+
console.log("");
|
|
136
|
+
console.log(color("=== Voxlert Setup ===", ANSI.bold));
|
|
137
|
+
console.log("");
|
|
138
|
+
for (const line of formatCurrentConfig(config, installedPlatforms)) {
|
|
139
|
+
console.log(` ${line}`);
|
|
140
|
+
}
|
|
141
|
+
console.log("");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
process.stdout.write(ANSI.hideCursor);
|
|
146
|
+
try {
|
|
147
|
+
const frames = Array.from({ length: 16 }, (_, index) => 4 + index * 6);
|
|
148
|
+
for (const shimmerStep of frames) {
|
|
149
|
+
process.stdout.write(ANSI.clear + ANSI.home + renderLogoFrame(config, installedPlatforms, shimmerStep));
|
|
150
|
+
await sleep(125);
|
|
151
|
+
}
|
|
152
|
+
process.stdout.write(ANSI.clear + ANSI.home + renderLogoFrame(config, installedPlatforms));
|
|
153
|
+
} finally {
|
|
154
|
+
process.stdout.write(ANSI.showCursor);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function printStep(number, title) {
|
|
159
|
+
console.log(color(`Step ${number}/6: ${title}`, ANSI.bold));
|
|
160
|
+
console.log("");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function printStatus(label, value) {
|
|
164
|
+
console.log(` ${color(label, ANSI.dim)} ${value}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function printSuccess(message) {
|
|
168
|
+
console.log(` ${color("OK", ANSI.green)} ${message}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function printWarning(message) {
|
|
172
|
+
console.log(` ${color("->", ANSI.yellow)} ${message}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function highlight(text) {
|
|
176
|
+
return color(text, ANSI.cyan);
|
|
177
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive setup wizard for Voxlert.
|
|
3
|
+
* Handles LLM provider selection, API key input, voice pack picking,
|
|
4
|
+
* TTS server detection, and Claude Code hook registration.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, copyFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import * as http from "http";
|
|
11
|
+
import * as https from "https";
|
|
12
|
+
import select from "@inquirer/select";
|
|
13
|
+
import checkbox from "@inquirer/checkbox";
|
|
14
|
+
import input from "@inquirer/input";
|
|
15
|
+
import confirm from "@inquirer/confirm";
|
|
16
|
+
import { loadConfig, saveConfig, ensureConfig } from "./config.js";
|
|
17
|
+
import { listPacks } from "./packs.js";
|
|
18
|
+
import { CONFIG_PATH, PACKS_DIR, CACHE_DIR, IS_NPM_GLOBAL, BUNDLED_PACKS_DIR, SCRIPT_DIR } from "./paths.js";
|
|
19
|
+
import { PACK_REGISTRY, DEFAULT_DOWNLOAD_PACK_IDS, getPackRegistryBaseUrl } from "./pack-registry.js";
|
|
20
|
+
import { LLM_PROVIDERS, getProvider } from "./providers.js";
|
|
21
|
+
import { registerHooks, installSkill, unregisterHooks, hasVoxlertHooks, hasInstalledSkill, removeSkill } from "./hooks.js";
|
|
22
|
+
import { registerCursorHooks, unregisterCursorHooks, hasCursorHooks } from "./cursor-hooks.js";
|
|
23
|
+
import { registerCodexNotify, getCodexConfigPath, unregisterCodexNotify, hasCodexNotify } from "./codex-config.js";
|
|
24
|
+
import { printSetupHeader, printStep, printStatus, printSuccess, printWarning, highlight } from "./setup-ui.js";
|
|
25
|
+
import {
|
|
26
|
+
QWEN_DOCS_URL,
|
|
27
|
+
CHATTERBOX_DOCS_URL,
|
|
28
|
+
probeTtsBackend,
|
|
29
|
+
chooseTtsBackend,
|
|
30
|
+
verifyTtsSetup,
|
|
31
|
+
} from "./tts-test.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate an API key by making a lightweight request to the provider.
|
|
35
|
+
*/
|
|
36
|
+
function validateApiKey(providerId, apiKey) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const provider = getProvider(providerId);
|
|
39
|
+
if (!provider) return resolve({ ok: false, error: "Unknown provider" });
|
|
40
|
+
|
|
41
|
+
let url;
|
|
42
|
+
let options;
|
|
43
|
+
|
|
44
|
+
const base = provider.baseUrl.replace(/\/+$/, "");
|
|
45
|
+
|
|
46
|
+
if (provider.format === "anthropic") {
|
|
47
|
+
// Anthropic: POST to /v1/messages with a tiny request
|
|
48
|
+
url = new URL(`${base}/v1/messages`);
|
|
49
|
+
const authHeaders = provider.authHeader(apiKey);
|
|
50
|
+
const payload = JSON.stringify({
|
|
51
|
+
model: provider.defaultModel,
|
|
52
|
+
max_tokens: 1,
|
|
53
|
+
messages: [{ role: "user", content: "hi" }],
|
|
54
|
+
});
|
|
55
|
+
options = {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: {
|
|
58
|
+
...authHeaders,
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
61
|
+
},
|
|
62
|
+
timeout: 8000,
|
|
63
|
+
};
|
|
64
|
+
const req = https.request(url, options, (res) => {
|
|
65
|
+
let data = "";
|
|
66
|
+
res.on("data", (chunk) => (data += chunk));
|
|
67
|
+
res.on("end", () => {
|
|
68
|
+
if (res.statusCode === 401) return resolve({ ok: false, error: "Invalid API key" });
|
|
69
|
+
if (res.statusCode === 403) return resolve({ ok: false, error: "API key lacks permissions" });
|
|
70
|
+
resolve({ ok: res.statusCode < 500 });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
req.on("error", (err) => resolve({ ok: false, error: err.message }));
|
|
74
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Timeout" }); });
|
|
75
|
+
req.write(payload);
|
|
76
|
+
req.end();
|
|
77
|
+
} else {
|
|
78
|
+
// OpenAI-compatible: GET /models
|
|
79
|
+
url = new URL(`${base}/models`);
|
|
80
|
+
const authHeaders = provider.authHeader(apiKey);
|
|
81
|
+
options = {
|
|
82
|
+
method: "GET",
|
|
83
|
+
headers: { ...authHeaders },
|
|
84
|
+
timeout: 8000,
|
|
85
|
+
};
|
|
86
|
+
const reqFn = url.protocol === "https:" ? https.request : http.request;
|
|
87
|
+
const req = reqFn(url, options, (res) => {
|
|
88
|
+
let data = "";
|
|
89
|
+
res.on("data", (chunk) => (data += chunk));
|
|
90
|
+
res.on("end", () => {
|
|
91
|
+
if (res.statusCode === 401) return resolve({ ok: false, error: "Invalid API key" });
|
|
92
|
+
if (res.statusCode === 403) return resolve({ ok: false, error: "API key lacks permissions" });
|
|
93
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300 });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
req.on("error", (err) => resolve({ ok: false, error: err.message }));
|
|
97
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Timeout" }); });
|
|
98
|
+
req.end();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Fetch a URL and return the response body as a Buffer.
|
|
105
|
+
* Rejects on non-2xx or network error.
|
|
106
|
+
*/
|
|
107
|
+
function fetchUrl(url, timeoutMs = 15000) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
let urlObj;
|
|
110
|
+
try {
|
|
111
|
+
urlObj = new URL(url);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return reject(e);
|
|
114
|
+
}
|
|
115
|
+
const reqFn = urlObj.protocol === "https:" ? https.request : http.request;
|
|
116
|
+
const req = reqFn(urlObj, { method: "GET", timeout: timeoutMs }, (res) => {
|
|
117
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
118
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
119
|
+
res.resume();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const chunks = [];
|
|
123
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
124
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
125
|
+
res.on("error", reject);
|
|
126
|
+
});
|
|
127
|
+
req.on("error", reject);
|
|
128
|
+
req.on("timeout", () => {
|
|
129
|
+
req.destroy();
|
|
130
|
+
reject(new Error("Timeout"));
|
|
131
|
+
});
|
|
132
|
+
req.end();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Download a voice pack from the registry base URL into PACKS_DIR/<id>/.
|
|
138
|
+
* Writes pack.json and voice.wav. Resolves on success, rejects on fetch/write error.
|
|
139
|
+
*/
|
|
140
|
+
async function downloadPack(packId, baseUrl) {
|
|
141
|
+
const dir = join(PACKS_DIR, packId);
|
|
142
|
+
mkdirSync(dir, { recursive: true });
|
|
143
|
+
|
|
144
|
+
const packUrl = `${baseUrl.replace(/\/$/, "")}/${packId}/pack.json`;
|
|
145
|
+
const voiceUrl = `${baseUrl.replace(/\/$/, "")}/${packId}/voice.wav`;
|
|
146
|
+
|
|
147
|
+
const [packBuf, voiceBuf] = await Promise.all([
|
|
148
|
+
fetchUrl(packUrl),
|
|
149
|
+
fetchUrl(voiceUrl),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
writeFileSync(join(dir, "pack.json"), packBuf);
|
|
153
|
+
writeFileSync(join(dir, "voice.wav"), voiceBuf);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Copy bundled packs to the user's packs directory (for npm global installs).
|
|
158
|
+
*/
|
|
159
|
+
function ensurePacks() {
|
|
160
|
+
if (!IS_NPM_GLOBAL) return;
|
|
161
|
+
if (!existsSync(BUNDLED_PACKS_DIR)) return;
|
|
162
|
+
|
|
163
|
+
mkdirSync(PACKS_DIR, { recursive: true });
|
|
164
|
+
for (const name of readdirSync(BUNDLED_PACKS_DIR)) {
|
|
165
|
+
const src = join(BUNDLED_PACKS_DIR, name);
|
|
166
|
+
const dest = join(PACKS_DIR, name);
|
|
167
|
+
mkdirSync(dest, { recursive: true });
|
|
168
|
+
|
|
169
|
+
const packJson = join(src, "pack.json");
|
|
170
|
+
if (existsSync(packJson)) {
|
|
171
|
+
copyFileSync(packJson, join(dest, "pack.json"));
|
|
172
|
+
}
|
|
173
|
+
const voiceWav = join(src, "voice.wav");
|
|
174
|
+
if (existsSync(voiceWav) && !existsSync(join(dest, "voice.wav"))) {
|
|
175
|
+
copyFileSync(voiceWav, join(dest, "voice.wav"));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function runSetup() {
|
|
181
|
+
// Ensure config exists
|
|
182
|
+
ensureConfig();
|
|
183
|
+
ensurePacks();
|
|
184
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
185
|
+
|
|
186
|
+
const config = loadConfig();
|
|
187
|
+
const currentBackend = config.llm_backend || "openrouter";
|
|
188
|
+
const currentProvider = getProvider(currentBackend);
|
|
189
|
+
const currentModel = config.llm_model || currentProvider?.defaultModel || "default";
|
|
190
|
+
const installedPlatforms = [];
|
|
191
|
+
if (hasVoxlertHooks() || hasInstalledSkill()) installedPlatforms.push("Claude");
|
|
192
|
+
if (hasCursorHooks()) installedPlatforms.push("Cursor");
|
|
193
|
+
if (hasCodexNotify()) installedPlatforms.push("Codex");
|
|
194
|
+
await printSetupHeader(config, installedPlatforms);
|
|
195
|
+
|
|
196
|
+
// --- Step 1: LLM Provider ---
|
|
197
|
+
printStep(1, "LLM Provider");
|
|
198
|
+
|
|
199
|
+
const providerChoices = [
|
|
200
|
+
...Object.entries(LLM_PROVIDERS).map(([id, p]) => ({
|
|
201
|
+
name: [
|
|
202
|
+
p.name,
|
|
203
|
+
id === currentBackend ? `(current: ${currentModel})` : "",
|
|
204
|
+
id === "openrouter" ? "(recommended)" : "",
|
|
205
|
+
"—",
|
|
206
|
+
p.description,
|
|
207
|
+
].filter(Boolean).join(" "),
|
|
208
|
+
value: id,
|
|
209
|
+
})),
|
|
210
|
+
{
|
|
211
|
+
name: currentBackend === "local" || !config.llm_api_key
|
|
212
|
+
? "Skip (current: fallback only) — fallback phrases only, no API key needed"
|
|
213
|
+
: "Skip — fallback phrases only, no API key needed",
|
|
214
|
+
value: "skip",
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
const chosenProvider = await select({
|
|
219
|
+
message: "Which LLM provider would you like to use?",
|
|
220
|
+
choices: providerChoices,
|
|
221
|
+
default: currentBackend !== "local" ? currentBackend : "openrouter",
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
let apiKey = null;
|
|
225
|
+
|
|
226
|
+
if (chosenProvider !== "skip") {
|
|
227
|
+
config.llm_backend = chosenProvider;
|
|
228
|
+
const provider = getProvider(chosenProvider);
|
|
229
|
+
|
|
230
|
+
// --- Step 2: API Key ---
|
|
231
|
+
console.log("");
|
|
232
|
+
printStep(2, "API Key");
|
|
233
|
+
printStatus("Get a key at:", provider.signupUrl);
|
|
234
|
+
console.log("");
|
|
235
|
+
|
|
236
|
+
const existingKey = config.llm_api_key ?? config.openrouter_api_key ?? "";
|
|
237
|
+
const maskedExisting = existingKey
|
|
238
|
+
? `${existingKey.slice(0, 4)}…${existingKey.slice(-4)}`
|
|
239
|
+
: "";
|
|
240
|
+
|
|
241
|
+
apiKey = await input({
|
|
242
|
+
message: "Paste your API key:",
|
|
243
|
+
default: existingKey || undefined,
|
|
244
|
+
transformer: (val) => {
|
|
245
|
+
if (!val) return maskedExisting || "";
|
|
246
|
+
if (val === existingKey) return maskedExisting;
|
|
247
|
+
if (val.length <= 8) return "****";
|
|
248
|
+
return val.slice(0, 4) + "…" + val.slice(-4);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (apiKey) {
|
|
253
|
+
process.stdout.write(" Validating key... ");
|
|
254
|
+
const result = await validateApiKey(chosenProvider, apiKey);
|
|
255
|
+
if (result.ok) {
|
|
256
|
+
console.log("valid!\n");
|
|
257
|
+
} else {
|
|
258
|
+
console.log(`could not validate (${result.error || "unknown error"})`);
|
|
259
|
+
const proceed = await confirm({
|
|
260
|
+
message: "Use this key anyway?",
|
|
261
|
+
default: true,
|
|
262
|
+
});
|
|
263
|
+
if (!proceed) {
|
|
264
|
+
apiKey = null;
|
|
265
|
+
printWarning("Skipped. Set it later with: voxlert config set llm_api_key <key>");
|
|
266
|
+
console.log("");
|
|
267
|
+
} else {
|
|
268
|
+
console.log("");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (apiKey) {
|
|
273
|
+
config.llm_api_key = apiKey;
|
|
274
|
+
// Clear legacy field if using the new unified field
|
|
275
|
+
if (chosenProvider === "openrouter") {
|
|
276
|
+
config.openrouter_api_key = apiKey;
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
config.llm_api_key = null;
|
|
280
|
+
config.openrouter_api_key = null;
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
config.llm_api_key = null;
|
|
284
|
+
config.openrouter_api_key = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Set default model for chosen provider
|
|
288
|
+
if (!config.llm_model && !config.openrouter_model) {
|
|
289
|
+
config.llm_model = provider.defaultModel;
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
config.llm_api_key = null;
|
|
293
|
+
config.openrouter_api_key = null;
|
|
294
|
+
console.log("");
|
|
295
|
+
printWarning("Using fallback phrases from the voice pack.");
|
|
296
|
+
console.log("");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- Step 3: Download voice packs (from GitHub) ---
|
|
300
|
+
console.log("");
|
|
301
|
+
printStep(3, "Download voice packs");
|
|
302
|
+
printStatus("Source", "Voxlert GitHub repo");
|
|
303
|
+
console.log("");
|
|
304
|
+
|
|
305
|
+
mkdirSync(PACKS_DIR, { recursive: true });
|
|
306
|
+
const existingPackIds = new Set();
|
|
307
|
+
try {
|
|
308
|
+
for (const entry of readdirSync(PACKS_DIR, { withFileTypes: true })) {
|
|
309
|
+
if (entry.isDirectory() && existsSync(join(PACKS_DIR, entry.name, "pack.json"))) {
|
|
310
|
+
existingPackIds.add(entry.name);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
// PACKS_DIR may not exist yet
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const packChoices = PACK_REGISTRY.map((p) => ({
|
|
318
|
+
name: existingPackIds.has(p.id) ? `${p.name} (already installed)` : p.name,
|
|
319
|
+
value: p.id,
|
|
320
|
+
checked: existingPackIds.size > 0
|
|
321
|
+
? existingPackIds.has(p.id)
|
|
322
|
+
: DEFAULT_DOWNLOAD_PACK_IDS.includes(p.id),
|
|
323
|
+
}));
|
|
324
|
+
|
|
325
|
+
const toDownload = await checkbox({
|
|
326
|
+
message: "Which voice packs do you want to install? (downloaded from GitHub)",
|
|
327
|
+
choices: packChoices,
|
|
328
|
+
required: false,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const baseUrl = getPackRegistryBaseUrl();
|
|
332
|
+
for (const packId of toDownload || []) {
|
|
333
|
+
if (existingPackIds.has(packId)) continue;
|
|
334
|
+
const pack = PACK_REGISTRY.find((p) => p.id === packId);
|
|
335
|
+
const label = pack ? pack.name : packId;
|
|
336
|
+
process.stdout.write(` Downloading ${label}... `);
|
|
337
|
+
try {
|
|
338
|
+
await downloadPack(packId, baseUrl);
|
|
339
|
+
console.log("done.");
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.log(`failed (${err.message}).`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- Step 4: Voice Pack ---
|
|
346
|
+
printStep(4, "Voice Pack");
|
|
347
|
+
|
|
348
|
+
const packs = listPacks();
|
|
349
|
+
if (packs.length > 0) {
|
|
350
|
+
const active = config.active_pack || "";
|
|
351
|
+
const packChoices = [
|
|
352
|
+
{
|
|
353
|
+
name: active === "random" ? "Random (current)" : "Random",
|
|
354
|
+
value: "random",
|
|
355
|
+
description: "Picks a different voice each time",
|
|
356
|
+
},
|
|
357
|
+
...packs.map((p) => ({
|
|
358
|
+
name: p.id === active ? `${p.name} (current)` : p.name,
|
|
359
|
+
value: p.id,
|
|
360
|
+
description: p.id,
|
|
361
|
+
})),
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
const chosenPack = await select({
|
|
365
|
+
message: "Choose a voice pack:",
|
|
366
|
+
choices: packChoices,
|
|
367
|
+
default: active || "sc2-adjutant",
|
|
368
|
+
});
|
|
369
|
+
config.active_pack = chosenPack;
|
|
370
|
+
} else {
|
|
371
|
+
printWarning("No voice packs found. Using default.");
|
|
372
|
+
console.log("");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// --- Step 5: TTS Server ---
|
|
376
|
+
console.log("");
|
|
377
|
+
printStep(5, "TTS Server");
|
|
378
|
+
|
|
379
|
+
printStatus("Recommended", "Qwen TTS for a more natural voice");
|
|
380
|
+
printStatus("Voice test", `Uses the voice you picked in Step 4 (${config.active_pack || "default"})`);
|
|
381
|
+
printStatus("Qwen TTS setup docs", QWEN_DOCS_URL);
|
|
382
|
+
printStatus("Chatterbox setup docs", CHATTERBOX_DOCS_URL);
|
|
383
|
+
console.log("");
|
|
384
|
+
|
|
385
|
+
process.stdout.write(" Checking Chatterbox (port 8004)... ");
|
|
386
|
+
const chatterboxUp = await probeTtsBackend(config, "chatterbox");
|
|
387
|
+
console.log(chatterboxUp ? "detected!" : "not running");
|
|
388
|
+
|
|
389
|
+
process.stdout.write(" Checking Qwen TTS (port 8100)... ");
|
|
390
|
+
const qwenUp = await probeTtsBackend(config, "qwen");
|
|
391
|
+
console.log(qwenUp ? "detected!" : "not running");
|
|
392
|
+
|
|
393
|
+
config.tts_backend = await chooseTtsBackend(config, { qwenUp, chatterboxUp });
|
|
394
|
+
await verifyTtsSetup(config, config.tts_backend);
|
|
395
|
+
|
|
396
|
+
// --- Step 6: Hooks (platforms) ---
|
|
397
|
+
console.log("");
|
|
398
|
+
printStep(6, "Hooks");
|
|
399
|
+
|
|
400
|
+
const platformChoices = [
|
|
401
|
+
{
|
|
402
|
+
name: "Claude Code",
|
|
403
|
+
value: "claude",
|
|
404
|
+
description: "Register in ~/.claude/settings.json + install skill",
|
|
405
|
+
checked: hasVoxlertHooks() || hasInstalledSkill(),
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: "Cursor",
|
|
409
|
+
value: "cursor",
|
|
410
|
+
description: "Register in ~/.cursor/hooks.json (Agent / Cmd+K)",
|
|
411
|
+
checked: hasCursorHooks(),
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
name: "Codex",
|
|
415
|
+
value: "codex",
|
|
416
|
+
description: "Install/update notify in ~/.codex/config.toml",
|
|
417
|
+
checked: hasCodexNotify(),
|
|
418
|
+
},
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
const selectedPlatforms = await checkbox({
|
|
422
|
+
message: "Which platforms do you want to install hooks for?",
|
|
423
|
+
choices: platformChoices,
|
|
424
|
+
required: false,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Determine the hook command for Claude Code (used when "claude" is selected)
|
|
428
|
+
const hookCommand = IS_NPM_GLOBAL ? "voxlert hook" : join(SCRIPT_DIR, "voxlert.sh");
|
|
429
|
+
const codexNotifyCommand = IS_NPM_GLOBAL
|
|
430
|
+
? ["voxlert", "codex-notify"]
|
|
431
|
+
: [process.execPath, join(SCRIPT_DIR, "src", "cli.js"), "codex-notify"];
|
|
432
|
+
|
|
433
|
+
if (selectedPlatforms.includes("claude")) {
|
|
434
|
+
const hookCount = registerHooks(hookCommand);
|
|
435
|
+
printSuccess(`Registered ${hookCount} hook events in ~/.claude/settings.json`);
|
|
436
|
+
if (installSkill()) {
|
|
437
|
+
printSuccess("Installed voxlert-config skill");
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
const removed = unregisterHooks();
|
|
441
|
+
const skillRemoved = removeSkill();
|
|
442
|
+
if (removed > 0) {
|
|
443
|
+
printWarning(`Removed ${removed} hook(s) from ~/.claude/settings.json`);
|
|
444
|
+
}
|
|
445
|
+
if (skillRemoved) {
|
|
446
|
+
printWarning("Removed voxlert-config skill");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (selectedPlatforms.includes("cursor")) {
|
|
451
|
+
const cursorCount = registerCursorHooks("voxlert cursor-hook");
|
|
452
|
+
printSuccess(`Registered ${cursorCount} hook events in ~/.cursor/hooks.json`);
|
|
453
|
+
printStatus("Next", "Restart Cursor to hear Voxlert in Agent Chat.");
|
|
454
|
+
} else {
|
|
455
|
+
const cursorRemoved = unregisterCursorHooks();
|
|
456
|
+
if (cursorRemoved > 0) {
|
|
457
|
+
printWarning(`Removed ${cursorRemoved} hook(s) from ~/.cursor/hooks.json`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (selectedPlatforms.includes("codex")) {
|
|
462
|
+
registerCodexNotify(codexNotifyCommand);
|
|
463
|
+
printSuccess(`Installed Codex notify command in ${getCodexConfigPath()}`);
|
|
464
|
+
} else {
|
|
465
|
+
const codexRemoved = unregisterCodexNotify();
|
|
466
|
+
if (codexRemoved) {
|
|
467
|
+
printWarning(`Removed Codex notify from ${getCodexConfigPath()}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (selectedPlatforms.length === 0) {
|
|
472
|
+
printWarning("No platforms selected. Run 'voxlert setup' again to install hooks later.");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --- Save config ---
|
|
476
|
+
saveConfig(config);
|
|
477
|
+
|
|
478
|
+
// --- Summary ---
|
|
479
|
+
console.log("");
|
|
480
|
+
console.log(highlight("=== Setup Complete ==="));
|
|
481
|
+
console.log("");
|
|
482
|
+
printStatus("Config", CONFIG_PATH);
|
|
483
|
+
if (chosenProvider !== "skip") {
|
|
484
|
+
const p = getProvider(config.llm_backend);
|
|
485
|
+
printStatus("LLM", `${p ? p.name : config.llm_backend} (${config.llm_model || p?.defaultModel || "default"})`);
|
|
486
|
+
} else {
|
|
487
|
+
printStatus("LLM", "Skipped (fallback phrases only)");
|
|
488
|
+
}
|
|
489
|
+
printStatus("Voice", config.active_pack);
|
|
490
|
+
printStatus("TTS", config.tts_backend);
|
|
491
|
+
console.log("");
|
|
492
|
+
console.log(` ${highlight("Start a new session in each platform you installed to hear Voxlert.")}`);
|
|
493
|
+
if (selectedPlatforms.includes("claude")) {
|
|
494
|
+
printStatus("Claude Code", "Start a new Claude Code session.");
|
|
495
|
+
}
|
|
496
|
+
if (selectedPlatforms.includes("cursor")) {
|
|
497
|
+
printStatus("Cursor", "Restart Cursor to hear Voxlert in Agent Chat.");
|
|
498
|
+
}
|
|
499
|
+
if (selectedPlatforms.includes("codex")) {
|
|
500
|
+
printStatus("Codex", "Start a new Codex session to pick up the notify config.");
|
|
501
|
+
}
|
|
502
|
+
printStatus("Reconfigure", "voxlert setup");
|
|
503
|
+
console.log("");
|
|
504
|
+
}
|