wispy-cli 2.1.0 → 2.2.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.
Files changed (2) hide show
  1. package/core/onboarding.mjs +489 -373
  2. package/package.json +5 -5
@@ -2,13 +2,18 @@
2
2
  * core/onboarding.mjs — Unified first-run wizard for Wispy
3
3
  *
4
4
  * Handles interactive setup from scratch:
5
- * - AI provider + API key
6
- * - Messaging channels (Telegram, Discord, Slack)
5
+ * - AI providers (multi-select, checkbox)
6
+ * - API key collection + validation
7
+ * - Default provider selection
8
+ * - Messaging channels (multi-select)
7
9
  * - Workstream
8
- * - Memory / personalization
10
+ * - About you (name, role, language)
9
11
  * - Security level
10
12
  * - Server / cloud mode
11
13
  *
14
+ * Uses @inquirer/prompts for proper interactive TUI (space-to-select,
15
+ * arrow-key navigation, masked password input).
16
+ *
12
17
  * Usage:
13
18
  * import { OnboardingWizard } from './onboarding.mjs';
14
19
  * const wizard = new OnboardingWizard();
@@ -18,9 +23,10 @@
18
23
 
19
24
  import os from "node:os";
20
25
  import path from "node:path";
21
- import { createInterface } from "node:readline";
22
26
  import { mkdir, writeFile, readFile } from "node:fs/promises";
23
27
 
28
+ import { confirm, checkbox, select, input, password } from "@inquirer/prompts";
29
+
24
30
  import {
25
31
  WISPY_DIR,
26
32
  CONFIG_PATH,
@@ -31,116 +37,119 @@ import {
31
37
  } from "./config.mjs";
32
38
 
33
39
  // ──────────────────────────────────────────────────────────────────────────────
34
- // ANSI helpers (no deps)
40
+ // ANSI helpers (no extra deps)
35
41
  // ──────────────────────────────────────────────────────────────────────────────
36
42
 
37
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
38
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
39
- const green = (s) => `\x1b[32m${s}\x1b[0m`;
40
- const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
41
- const yellow= (s) => `\x1b[33m${s}\x1b[0m`;
42
- const red = (s) => `\x1b[31m${s}\x1b[0m`;
43
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
44
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
45
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
46
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
47
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
48
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
43
49
 
44
50
  // ──────────────────────────────────────────────────────────────────────────────
45
51
  // Provider registry (display order for wizard)
46
52
  // ──────────────────────────────────────────────────────────────────────────────
47
53
 
48
54
  const PROVIDER_LIST = [
49
- { key: "google", label: "Google AI (Gemini)", tag: "free tier available recommended", signupUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-2.5-flash" },
50
- { key: "anthropic", label: "Anthropic (Claude)", tag: "best quality", signupUrl: "https://console.anthropic.com/settings/keys", defaultModel: "claude-sonnet-4-20250514" },
51
- { key: "openai", label: "OpenAI (GPT-4o)", tag: "", signupUrl: "https://platform.openai.com/api-keys", defaultModel: "gpt-4o" },
52
- { key: "groq", label: "Groq", tag: "fastest inference, free", signupUrl: "https://console.groq.com/keys", defaultModel: "llama-3.3-70b-versatile" },
53
- { key: "deepseek", label: "DeepSeek", tag: "cheap and good", signupUrl: "https://platform.deepseek.com/api_keys", defaultModel: "deepseek-chat" },
54
- { key: "ollama", label: "Ollama", tag: "local, no API key needed", signupUrl: null, defaultModel: "llama3.2" },
55
- { key: "openrouter", label: "OpenRouter", tag: "access any model", signupUrl: "https://openrouter.ai/keys", defaultModel: "anthropic/claude-sonnet-4-20250514" },
56
- { key: null, label: "I'll set this up later", tag: "", signupUrl: null, defaultModel: null },
55
+ { key: "google", label: "Google AI (Gemini) free tier ⭐", defaultModel: "gemini-2.5-flash", signupUrl: "https://aistudio.google.com/apikey" },
56
+ { key: "anthropic", label: "Anthropic (Claude) — best quality", defaultModel: "claude-sonnet-4-20250514", signupUrl: "https://console.anthropic.com/settings/keys" },
57
+ { key: "openai", label: "OpenAI (GPT-4o)", defaultModel: "gpt-4o", signupUrl: "https://platform.openai.com/api-keys" },
58
+ { key: "groq", label: "Groq fastest, free", defaultModel: "llama-3.3-70b-versatile", signupUrl: "https://console.groq.com/keys" },
59
+ { key: "deepseek", label: "DeepSeek cheap & good", defaultModel: "deepseek-chat", signupUrl: "https://platform.deepseek.com/api_keys" },
60
+ { key: "ollama", label: "Ollama local, no key needed", defaultModel: "llama3.2", signupUrl: null },
61
+ { key: "openrouter", label: "OpenRouter access any model", defaultModel: "anthropic/claude-sonnet-4-20250514", signupUrl: "https://openrouter.ai/keys" },
57
62
  ];
58
63
 
64
+ function getProviderLabel(key) {
65
+ return PROVIDER_LIST.find((p) => p.key === key)?.label ?? key;
66
+ }
67
+
68
+ function getProviderDefaultModel(key) {
69
+ return PROVIDER_LIST.find((p) => p.key === key)?.defaultModel ?? "unknown";
70
+ }
71
+
59
72
  // ──────────────────────────────────────────────────────────────────────────────
60
73
  // Security level definitions
61
74
  // ──────────────────────────────────────────────────────────────────────────────
62
75
 
63
76
  const SECURITY_LEVELS = {
64
- careful: {
65
- label: "Careful",
66
- desc: "ask before running commands or modifying files (recommended)",
67
- permissions: { run_command: "approve", write_file: "approve", git: "approve" },
68
- },
69
- balanced: {
70
- label: "Balanced",
71
- desc: "ask for dangerous commands only",
72
- permissions: { run_command: "approve", write_file: "notify", git: "approve" },
73
- },
74
- trust: {
75
- label: "Trust me",
76
- desc: "auto-approve everything (power users)",
77
- permissions: { run_command: "auto", write_file: "auto", git: "auto" },
78
- },
77
+ careful: { label: "Careful", permissions: { run_command: "approve", write_file: "approve", git: "approve" } },
78
+ balanced: { label: "Balanced", permissions: { run_command: "approve", write_file: "notify", git: "approve" } },
79
+ yolo: { label: "YOLO", permissions: { run_command: "auto", write_file: "auto", git: "auto" } },
79
80
  };
80
81
 
81
82
  // ──────────────────────────────────────────────────────────────────────────────
82
- // Readline helpers
83
- // ──────────────────────────────────────────────────────────────────────────────
84
-
85
- function makeRl() {
86
- return createInterface({ input: process.stdin, output: process.stdout });
87
- }
88
-
89
- function ask(rl, question) {
90
- return new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
91
- }
92
-
93
- async function askWithDefault(rl, question, defaultVal) {
94
- const answer = await ask(rl, question);
95
- return answer === "" ? defaultVal : answer;
96
- }
97
-
98
- // ──────────────────────────────────────────────────────────────────────────────
99
- // API key validation (basic connectivity check)
83
+ // API key validation
100
84
  // ──────────────────────────────────────────────────────────────────────────────
101
85
 
102
86
  async function validateApiKey(provider, key) {
103
87
  if (provider === "ollama") return { ok: true, model: "llama3.2" };
104
88
  try {
105
- const info = PROVIDERS[provider];
106
- if (!info) return { ok: false, error: "Unknown provider" };
107
-
108
89
  if (provider === "google") {
109
- const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash?key=${key}`;
110
- const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
111
- if (r.ok) return { ok: true, model: "gemini-2.5-flash" };
112
- return { ok: false, error: `HTTP ${r.status}` };
90
+ const r = await fetch(
91
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash?key=${key}`,
92
+ { signal: AbortSignal.timeout(6000) }
93
+ );
94
+ return r.ok ? { ok: true, model: "gemini-2.5-flash" } : { ok: false, error: `HTTP ${r.status}` };
113
95
  }
114
-
115
96
  if (provider === "anthropic") {
116
97
  const r = await fetch("https://api.anthropic.com/v1/models", {
117
98
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
118
99
  signal: AbortSignal.timeout(6000),
119
100
  });
120
- if (r.ok) return { ok: true, model: "claude-sonnet-4-20250514" };
121
- return { ok: false, error: `HTTP ${r.status}` };
101
+ return r.ok ? { ok: true, model: "claude-sonnet-4-20250514" } : { ok: false, error: `HTTP ${r.status}` };
122
102
  }
123
-
124
103
  if (provider === "openai") {
125
104
  const r = await fetch("https://api.openai.com/v1/models", {
126
105
  headers: { Authorization: `Bearer ${key}` },
127
106
  signal: AbortSignal.timeout(6000),
128
107
  });
129
- if (r.ok) return { ok: true, model: "gpt-4o" };
130
- return { ok: false, error: `HTTP ${r.status}` };
108
+ return r.ok ? { ok: true, model: "gpt-4o" } : { ok: false, error: `HTTP ${r.status}` };
131
109
  }
132
-
133
- // For other providers just trust the key format
134
- return { ok: true, model: info.defaultModel };
110
+ if (provider === "groq") {
111
+ const r = await fetch("https://api.groq.com/openai/v1/models", {
112
+ headers: { Authorization: `Bearer ${key}` },
113
+ signal: AbortSignal.timeout(6000),
114
+ });
115
+ return r.ok ? { ok: true, model: "llama-3.3-70b-versatile" } : { ok: false, error: `HTTP ${r.status}` };
116
+ }
117
+ if (provider === "deepseek") {
118
+ const r = await fetch("https://api.deepseek.com/models", {
119
+ headers: { Authorization: `Bearer ${key}` },
120
+ signal: AbortSignal.timeout(6000),
121
+ });
122
+ return r.ok ? { ok: true, model: "deepseek-chat" } : { ok: false, error: `HTTP ${r.status}` };
123
+ }
124
+ if (provider === "openrouter") {
125
+ const r = await fetch("https://openrouter.ai/api/v1/models", {
126
+ headers: { Authorization: `Bearer ${key}` },
127
+ signal: AbortSignal.timeout(6000),
128
+ });
129
+ return r.ok ? { ok: true, model: "anthropic/claude-sonnet-4-20250514" } : { ok: false, error: `HTTP ${r.status}` };
130
+ }
131
+ // unknown provider — trust key format
132
+ return { ok: true, model: getProviderDefaultModel(provider) };
135
133
  } catch (err) {
136
134
  return { ok: false, error: err.message };
137
135
  }
138
136
  }
139
137
 
140
138
  // ──────────────────────────────────────────────────────────────────────────────
141
- // Save API key to shell env file
139
+ // Channel helpers
142
140
  // ──────────────────────────────────────────────────────────────────────────────
143
141
 
142
+ async function validateTelegramToken(token) {
143
+ try {
144
+ const r = await fetch(`https://api.telegram.org/bot${token}/getMe`, { signal: AbortSignal.timeout(6000) });
145
+ if (r.ok) {
146
+ const data = await r.json();
147
+ return { ok: true, username: data.result?.username };
148
+ }
149
+ return { ok: false };
150
+ } catch { return { ok: false }; }
151
+ }
152
+
144
153
  async function saveKeyToEnvFile(provider, key, filePath) {
145
154
  const info = PROVIDERS[provider];
146
155
  if (!info || !info.envKeys?.[0]) return;
@@ -148,7 +157,6 @@ async function saveKeyToEnvFile(provider, key, filePath) {
148
157
  const line = `\nexport ${envVar}="${key}" # wispy-cli\n`;
149
158
  try {
150
159
  const existing = await readFile(filePath, "utf8").catch(() => "");
151
- // Remove old entry if present
152
160
  const filtered = existing
153
161
  .split("\n")
154
162
  .filter((l) => !l.includes(`${envVar}=`) || !l.includes("wispy-cli"))
@@ -160,18 +168,51 @@ async function saveKeyToEnvFile(provider, key, filePath) {
160
168
  }
161
169
 
162
170
  // ──────────────────────────────────────────────────────────────────────────────
163
- // Channel validation (just ping the token)
171
+ // Summary box printer
164
172
  // ──────────────────────────────────────────────────────────────────────────────
165
173
 
166
- async function validateTelegramToken(token) {
167
- try {
168
- const r = await fetch(`https://api.telegram.org/bot${token}/getMe`, { signal: AbortSignal.timeout(6000) });
169
- if (r.ok) {
170
- const data = await r.json();
171
- return { ok: true, username: data.result?.username };
172
- }
173
- return { ok: false };
174
- } catch { return { ok: false }; }
174
+ function printSummaryBox(config) {
175
+ const providerKeys = Object.keys(config.providers ?? {});
176
+ const defaultP = config.defaultProvider ?? providerKeys[0];
177
+ const defaultModel = config.providers?.[defaultP]?.model ?? getProviderDefaultModel(defaultP ?? "");
178
+ const defaultLabel = defaultP ? `${getProviderLabel(defaultP)} (${defaultModel})` : "none";
179
+
180
+ const providerNames = providerKeys.map((k) => getProviderLabel(k).split(" —")[0]).join(", ") || "none";
181
+
182
+ const channelKeys = Object.entries(config.channels ?? {})
183
+ .filter(([, v]) => v?.enabled)
184
+ .map(([k]) => k.charAt(0).toUpperCase() + k.slice(1));
185
+ const channelsStr = channelKeys.length > 0 ? channelKeys.map((c) => `${c} ✅`).join(", ") : "none";
186
+
187
+ const workstream = config.workstream ?? "default";
188
+ const security = SECURITY_LEVELS[config.security ?? "careful"]?.label ?? "Careful";
189
+ const lang = config.language ?? "auto";
190
+
191
+ const lines = [
192
+ ` ✅ ${bold("Wispy is ready!")} `,
193
+ ` `,
194
+ ` Providers: ${providerNames}`,
195
+ ` Default: ${defaultLabel}`,
196
+ ` Channels: ${channelsStr}`,
197
+ ` Workstream: ${workstream}`,
198
+ ` Security: ${security} 🔒`,
199
+ ` Language: ${lang}`,
200
+ ` `,
201
+ ` ${cyan("wispy")} — interactive chat `,
202
+ ` ${cyan("wispy tui")} — workspace UI `,
203
+ ` ${cyan("wispy --serve")} — start channels + cron `,
204
+ ];
205
+
206
+ const w = 48;
207
+ const bar = "─".repeat(w);
208
+ console.log(`\n┌${bar}┐`);
209
+ for (const line of lines) {
210
+ // Strip ANSI for length calc
211
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
212
+ const pad = Math.max(0, w - plain.length);
213
+ console.log(`│${line}${" ".repeat(pad)}│`);
214
+ }
215
+ console.log(`└${bar}┘\n`);
175
216
  }
176
217
 
177
218
  // ──────────────────────────────────────────────────────────────────────────────
@@ -185,289 +226,392 @@ export class OnboardingWizard {
185
226
 
186
227
  // ── Step 1: Welcome ────────────────────────────────────────────────────────
187
228
 
188
- async stepWelcome(rl) {
229
+ async stepWelcome() {
189
230
  console.log("");
190
231
  console.log(`🌿 ${bold("Welcome to Wispy")} — your personal AI assistant`);
232
+ console.log(dim(" This takes about 2 minutes. Press Ctrl+C anytime to skip.\n"));
233
+
234
+ let ready;
235
+ try {
236
+ ready = await confirm({
237
+ message: "Ready to set up?",
238
+ default: true,
239
+ });
240
+ } catch { ready = false; }
241
+
242
+ if (!ready) {
243
+ console.log(dim("\n Setup skipped. Run 'wispy setup' anytime.\n"));
244
+ process.exit(0);
245
+ }
191
246
  console.log("");
192
- console.log("Let's get you set up. This takes about 2 minutes.");
193
- console.log(dim("Press Enter to start, or Ctrl+C to skip (you can run 'wispy setup' later)."));
194
- console.log("");
195
- await ask(rl, "");
196
247
  }
197
248
 
198
- // ── Step 2: AI Provider ────────────────────────────────────────────────────
249
+ // ── Step 2: AI Providers (multi-select) ────────────────────────────────────
199
250
 
200
- async stepProvider(rl) {
201
- console.log(`🤖 ${bold("Which AI provider do you want to use?")}\n`);
202
- PROVIDER_LIST.forEach((p, i) => {
203
- const tag = p.tag ? dim(` — ${p.tag}`) : "";
204
- console.log(` ${i + 1}. ${p.label}${tag}`);
205
- });
206
- console.log("");
251
+ async stepProvider() {
252
+ console.log(`🤖 ${bold("AI Providers")}\n`);
207
253
 
208
- const choiceStr = await askWithDefault(rl, "Choice [1]: ", "1");
209
- const choice = Math.min(Math.max(parseInt(choiceStr) || 1, 1), PROVIDER_LIST.length);
210
- const selected = PROVIDER_LIST[choice - 1];
254
+ let selectedKeys = [];
255
+ try {
256
+ selectedKeys = await checkbox({
257
+ message: "Which AI providers do you want to use? (space to select, enter to confirm)",
258
+ choices: PROVIDER_LIST.map((p, i) => ({
259
+ name: p.label,
260
+ value: p.key,
261
+ checked: i === 0, // Google pre-checked
262
+ })),
263
+ validate: (val) => val.length > 0 || "Select at least one provider (or press Ctrl+C to skip)",
264
+ });
265
+ } catch {
266
+ console.log(dim("\n Skipped. Run 'wispy setup provider' later.\n"));
267
+ return {};
268
+ }
211
269
 
212
- if (!selected.key) {
213
- console.log(dim("\n OK run 'wispy setup provider' later to configure.\n"));
270
+ if (!selectedKeys || selectedKeys.length === 0) {
271
+ console.log(dim("\n No providers selected. Run 'wispy setup provider' later.\n"));
214
272
  return {};
215
273
  }
216
274
 
217
- let apiKey = null;
275
+ // Collect API keys for each selected provider
276
+ const providers = {};
277
+ for (const provKey of selectedKeys) {
278
+ if (provKey === "ollama") {
279
+ process.stdout.write(`\n ${dim("Checking Ollama at http://localhost:11434...")}`);
280
+ try {
281
+ const r = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(3000) });
282
+ if (r.ok) {
283
+ process.stdout.write(green(" ✅\n"));
284
+ providers.ollama = { model: "llama3.2" };
285
+ continue;
286
+ }
287
+ } catch {}
288
+ process.stdout.write(yellow(" ⚠️ not running — saved anyway\n"));
289
+ providers.ollama = { model: "llama3.2" };
290
+ continue;
291
+ }
292
+
293
+ const info = PROVIDER_LIST.find((p) => p.key === provKey);
294
+ console.log("");
295
+ if (info?.signupUrl) {
296
+ console.log(dim(` Get a key at: ${info.signupUrl}`));
297
+ }
218
298
 
219
- if (selected.key === "ollama") {
220
- console.log(`\n ${dim("Checking Ollama at http://localhost:11434...")}`);
221
- try {
222
- const r = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(3000) });
223
- if (r.ok) {
224
- console.log(green("\n ✅ Ollama found! Using llama3.2\n"));
225
- return { provider: "ollama", model: "llama3.2" };
299
+ let validated = false;
300
+ let attempts = 0;
301
+ let apiKey = null;
302
+
303
+ while (!validated && attempts < 3) {
304
+ try {
305
+ apiKey = await password({
306
+ message: ` 🔑 ${info?.label ?? provKey} API key:`,
307
+ mask: "*",
308
+ });
309
+ } catch {
310
+ console.log(dim(`\n Skipping ${provKey}.\n`));
311
+ break;
226
312
  }
227
- } catch {}
228
- console.log(yellow("\n ⚠️ Ollama not running. Start it with: ollama serve\n"));
229
- return { provider: "ollama", model: "llama3.2" };
230
- }
231
313
 
232
- // Need API key
233
- if (selected.signupUrl) {
234
- console.log(`\n Paste your ${selected.label} API key`);
235
- console.log(dim(` (get one at ${selected.signupUrl})`));
236
- }
237
- console.log("");
314
+ if (!apiKey || apiKey.trim() === "") {
315
+ console.log(dim(` Skipping ${provKey}.`));
316
+ break;
317
+ }
238
318
 
239
- let validated = false;
240
- let attempts = 0;
241
- while (!validated && attempts < 3) {
242
- apiKey = await ask(rl, " Key: ");
243
- if (!apiKey) {
244
- console.log(dim(" Skipped. Run 'wispy setup provider' later.\n"));
245
- return {};
246
- }
247
- attempts++;
248
-
249
- process.stdout.write(" Validating...");
250
- const result = await validateApiKey(selected.key, apiKey);
251
- if (result.ok) {
252
- console.log(green(` Connected! Using ${result.model || selected.defaultModel}`));
253
- validated = true;
254
- } else {
255
- console.log(red(` ${result.error || "Invalid key"}`));
256
- if (attempts < 3) console.log(dim(" Try again, or press Enter to skip."));
319
+ attempts++;
320
+ process.stdout.write(" Validating...");
321
+ const result = await validateApiKey(provKey, apiKey.trim());
322
+
323
+ if (result.ok) {
324
+ console.log(green(` Connected! (${result.model ?? getProviderDefaultModel(provKey)})`));
325
+ providers[provKey] = {
326
+ apiKey: apiKey.trim(),
327
+ model: result.model ?? getProviderDefaultModel(provKey),
328
+ };
329
+ validated = true;
330
+ } else {
331
+ console.log(red(` ✗ ${result.error ?? "Invalid key"}`));
332
+ if (attempts < 3) console.log(dim(" Try again, or press Enter to skip."));
333
+ else {
334
+ // save anyway with warning
335
+ console.log(yellow(" Saving key anyway check it later."));
336
+ providers[provKey] = {
337
+ apiKey: apiKey.trim(),
338
+ model: getProviderDefaultModel(provKey),
339
+ };
340
+ }
341
+ }
257
342
  }
258
343
  }
259
344
 
260
- if (!validated) {
261
- console.log(dim("\n Skipping provider setup. Run 'wispy setup provider' later.\n"));
345
+ const providerKeys = Object.keys(providers);
346
+ if (providerKeys.length === 0) {
262
347
  return {};
263
348
  }
264
349
 
265
- // Where to save the key
266
- console.log(`\n Save API key to:`);
267
- console.log(` 1. ~/.wispy/config.json ${dim("(recommended)")}`);
268
- console.log(` 2. ~/.zshenv ${dim("(system-wide)")}`);
269
- console.log(` 3. Both`);
270
- console.log("");
271
- const saveChoice = await askWithDefault(rl, " Choice [1]: ", "1");
272
-
273
- if (saveChoice === "2" || saveChoice === "3") {
274
- const envFile = path.join(os.homedir(), ".zshenv");
275
- await saveKeyToEnvFile(selected.key, apiKey, envFile);
276
- console.log(green(` ✅ Saved to ~/.zshenv`));
350
+ // Step 3: Default provider (if multiple)
351
+ let defaultProvider = providerKeys[0];
352
+ if (providerKeys.length > 1) {
353
+ console.log("");
354
+ try {
355
+ defaultProvider = await select({
356
+ message: "⭐ Which provider should be the default?",
357
+ choices: providerKeys.map((k) => ({
358
+ name: getProviderLabel(k).split(" ")[0],
359
+ value: k,
360
+ })),
361
+ });
362
+ } catch {
363
+ defaultProvider = providerKeys[0];
364
+ }
277
365
  }
278
366
 
279
- const storeInConfig = saveChoice !== "2"; // save in config unless env-only
280
-
281
- const model = selected.defaultModel;
282
367
  console.log("");
283
-
284
- return {
285
- provider: selected.key,
286
- model,
287
- ...(storeInConfig ? { apiKey } : {}),
288
- };
368
+ return { providers, defaultProvider };
289
369
  }
290
370
 
291
- // ── Step 3: Channels ───────────────────────────────────────────────────────
292
-
293
- async stepChannels(rl) {
294
- console.log(`📱 ${bold("Want to connect messaging channels?")} ${dim("(you can skip and add later)")}\n`);
295
- console.log(" 1. Telegram bot — chat with your AI on Telegram");
296
- console.log(" 2. Discord bot — add AI to your Discord server");
297
- console.log(" 3. Slack bot — integrate with your workspace");
298
- console.log(" 4. Skip for now");
299
- console.log("");
371
+ // ── Step 4: Channels (multi-select) ───────────────────────────────────────
300
372
 
301
- const input = await askWithDefault(rl, "Choice (comma-separated, e.g. 1,2) [4]: ", "4");
302
- const choices = input.split(",").map((s) => parseInt(s.trim())).filter((n) => !isNaN(n));
373
+ async stepChannels() {
374
+ console.log(`📱 ${bold("Messaging Channels")}\n`);
303
375
 
304
- const channels = {
305
- telegram: { enabled: false },
306
- discord: { enabled: false },
307
- slack: { enabled: false },
308
- };
309
-
310
- if (choices.includes(4) || choices.length === 0) {
311
- console.log(dim("\n Channels skipped. Run 'wispy setup channels' later.\n"));
312
- return { channels };
376
+ let selectedChannels = [];
377
+ try {
378
+ selectedChannels = await checkbox({
379
+ message: "Connect messaging channels? (space to select, enter to skip)",
380
+ choices: [
381
+ { name: "Telegram", value: "telegram" },
382
+ { name: "Discord", value: "discord" },
383
+ { name: "Slack", value: "slack" },
384
+ { name: "WhatsApp", value: "whatsapp" },
385
+ { name: "Signal", value: "signal" },
386
+ { name: "Email", value: "email" },
387
+ ],
388
+ });
389
+ } catch {
390
+ console.log(dim(" Channels skipped.\n"));
391
+ return { channels: {} };
313
392
  }
314
393
 
315
- if (choices.includes(1)) {
316
- console.log(`\n 🤖 ${bold("Telegram Setup")}`);
317
- console.log(" 1. Open @BotFather on Telegram");
318
- console.log(" 2. Send /newbot and follow prompts");
319
- console.log(" 3. Paste the bot token here:");
394
+ const channels = {};
395
+
396
+ for (const ch of selectedChannels) {
320
397
  console.log("");
398
+ if (ch === "telegram") {
399
+ console.log(` 🤖 ${bold("Telegram")} — create a bot at @BotFather → /newbot`);
400
+ try {
401
+ const token = await input({ message: " Bot token (or Enter to skip):", default: "" });
402
+ if (token) {
403
+ process.stdout.write(" Checking...");
404
+ const result = await validateTelegramToken(token);
405
+ if (result.ok) {
406
+ console.log(green(` ✅ @${result.username}`));
407
+ } else {
408
+ console.log(yellow(" ⚠️ Could not verify — saved anyway"));
409
+ }
410
+ channels.telegram = { enabled: true, token };
411
+ }
412
+ } catch { /* skipped */ }
413
+ }
321
414
 
322
- const token = await ask(rl, " Token: ");
323
- if (token) {
324
- process.stdout.write(" Checking...");
325
- const result = await validateTelegramToken(token);
326
- if (result.ok) {
327
- console.log(green(` Bot connected! @${result.username}`));
328
- channels.telegram = { enabled: true, token };
329
- } else {
330
- console.log(yellow(" ⚠️ Could not verify token saved anyway"));
331
- channels.telegram = { enabled: true, token };
332
- }
415
+ if (ch === "discord") {
416
+ console.log(` 🤖 ${bold("Discord")} — https://discord.com/developers/applications → Bot → Token`);
417
+ try {
418
+ const token = await password({ message: " Bot token (or Enter to skip):", mask: "*" });
419
+ if (token) {
420
+ channels.discord = { enabled: true, token };
421
+ console.log(green(" ✅ Token saved"));
422
+ }
423
+ } catch { /* skipped */ }
333
424
  }
334
- }
335
425
 
336
- if (choices.includes(2)) {
337
- console.log(`\n 🤖 ${bold("Discord Setup")}`);
338
- console.log(" 1. Go to https://discord.com/developers/applications");
339
- console.log(" 2. Create a new application Bot Reset Token");
340
- console.log(" 3. Paste the bot token here:");
341
- console.log("");
426
+ if (ch === "slack") {
427
+ console.log(` 🤖 ${bold("Slack")} — https://api.slack.com/apps → Bot Token (xoxb-...)`);
428
+ try {
429
+ const token = await password({ message: " Bot token (xoxb-..., or Enter to skip):", mask: "*" });
430
+ if (token) {
431
+ const signingSecret = await password({ message: " Signing secret (or Enter to skip):", mask: "*" });
432
+ channels.slack = { enabled: true, token, ...(signingSecret ? { signingSecret } : {}) };
433
+ console.log(green(" ✅ Credentials saved"));
434
+ }
435
+ } catch { /* skipped */ }
436
+ }
342
437
 
343
- const token = await ask(rl, " Token: ");
344
- if (token) {
345
- channels.discord = { enabled: true, token };
346
- console.log(green(" ✅ Token saved"));
438
+ if (ch === "whatsapp") {
439
+ console.log(dim(` 📱 WhatsApp requires whatsapp-web.js. Run 'wispy setup channels' to configure.`));
440
+ channels.whatsapp = { enabled: false, _pending: true };
347
441
  }
348
- }
349
442
 
350
- if (choices.includes(3)) {
351
- console.log(`\n 🤖 ${bold("Slack Setup")}`);
352
- console.log(" 1. Go to https://api.slack.com/apps Create New App");
353
- console.log(" 2. Add Bot Token Scopes: chat:write, channels:read");
354
- console.log(" 3. Install to workspace → copy Bot User OAuth Token");
355
- console.log("");
443
+ if (ch === "signal") {
444
+ console.log(dim(` 📡 Signal support is experimental. Run 'wispy setup channels' to configure.`));
445
+ channels.signal = { enabled: false, _pending: true };
446
+ }
356
447
 
357
- const token = await ask(rl, " Bot Token (xoxb-...): ");
358
- if (token) {
359
- const signingSecret = await ask(rl, " Signing Secret: ");
360
- channels.slack = { enabled: true, token, signingSecret };
361
- console.log(green(" ✅ Credentials saved"));
448
+ if (ch === "email") {
449
+ console.log(` 📧 ${bold("Email")} — IMAP/SMTP`);
450
+ try {
451
+ const emailAddr = await input({ message: " Email address (or Enter to skip):", default: "" });
452
+ if (emailAddr) {
453
+ const emailPass = await password({ message: " App password:", mask: "*" });
454
+ channels.email = {
455
+ enabled: true,
456
+ address: emailAddr,
457
+ password: emailPass,
458
+ };
459
+ console.log(green(" ✅ Email saved (run 'wispy setup channels' for IMAP/SMTP settings)"));
460
+ }
461
+ } catch { /* skipped */ }
362
462
  }
363
463
  }
364
464
 
465
+ if (Object.keys(channels).length === 0) {
466
+ console.log(dim(" No channels configured. Run 'wispy setup channels' later.\n"));
467
+ }
365
468
  console.log("");
366
469
  return { channels };
367
470
  }
368
471
 
369
- // ── Step 4: Workstream ─────────────────────────────────────────────────────
472
+ // ── Step 5: Workstream ─────────────────────────────────────────────────────
370
473
 
371
- async stepWorkstream(rl) {
372
- console.log(`📂 ${bold("Set up your first workstream?")}\n`);
373
- console.log(" A workstream keeps conversations and context separate per project.\n");
374
- console.log(" 1. Yes, create one now");
375
- console.log(" 2. Skip (use default workstream)");
376
- console.log("");
377
-
378
- const choice = await askWithDefault(rl, "Choice [2]: ", "2");
474
+ async stepWorkstream() {
475
+ console.log(`📂 ${bold("Workstream")}\n`);
476
+ let create = false;
477
+ try {
478
+ create = await confirm({
479
+ message: "Create your first workstream?",
480
+ default: false,
481
+ });
482
+ } catch { /* skipped */ }
379
483
 
380
- if (choice !== "1") {
484
+ if (!create) {
381
485
  console.log(dim(" Using default workstream.\n"));
382
486
  return { workstream: "default" };
383
487
  }
384
488
 
385
- const name = await ask(rl, " Workstream name (e.g., my-app, backend, homework): ");
386
- const clean = (name || "default").replace(/[^a-z0-9_-]/gi, "-").toLowerCase() || "default";
489
+ let wsName = "default";
490
+ try {
491
+ wsName = await input({
492
+ message: "Workstream name:",
493
+ default: "default",
494
+ });
495
+ wsName = (wsName || "default").replace(/[^a-z0-9_-]/gi, "-").toLowerCase() || "default";
496
+ } catch { wsName = "default"; }
387
497
 
388
- console.log(green(` ✅ Workstream "${clean}" set.\n`));
389
- return { workstream: clean };
498
+ console.log(green(` ✅ Workstream "${wsName}" created.\n`));
499
+ return { workstream: wsName };
390
500
  }
391
501
 
392
- // ── Step 5: Memory ─────────────────────────────────────────────────────────
502
+ // ── Step 6: About you ─────────────────────────────────────────────────────
503
+
504
+ async stepMemory() {
505
+ console.log(`👤 ${bold("About You")}\n`);
393
506
 
394
- async stepMemory(rl) {
395
- console.log(`🧠 ${bold("Tell Wispy about yourself")} ${dim("(helps personalize responses)")}\n`);
507
+ let userName = "";
508
+ let userRole = "";
509
+ let language = "auto";
396
510
 
397
- const name = await ask(rl, " Your name (optional): ");
398
- const role = await askWithDefault(rl, " Your role (e.g., developer, student, designer) [skip]: ", "");
399
- const lang = await askWithDefault(rl, " Preferred language (e.g., Korean, English) [auto-detect]: ", "");
511
+ try {
512
+ userName = await input({ message: "Your name (optional):", default: "" });
513
+ } catch { /* skipped */ }
514
+
515
+ try {
516
+ userRole = await input({ message: "Your role (developer, student, designer...):", default: "" });
517
+ } catch { /* skipped */ }
400
518
 
401
- // Build user.md
519
+ try {
520
+ language = await select({
521
+ message: "🌐 Preferred language:",
522
+ choices: [
523
+ { name: "Auto-detect", value: "auto" },
524
+ { name: "English", value: "en" },
525
+ { name: "한국어", value: "ko" },
526
+ { name: "日本語", value: "ja" },
527
+ { name: "中文", value: "zh" },
528
+ ],
529
+ default: "auto",
530
+ });
531
+ } catch { language = "auto"; }
532
+
533
+ // Write memory/user.md
402
534
  const lines = ["# About Me", ""];
403
- if (name) lines.push(`**Name:** ${name}`, "");
404
- if (role) lines.push(`**Role:** ${role}`, "");
405
- if (lang) lines.push(`**Language:** ${lang}`, "");
535
+ if (userName) lines.push(`**Name:** ${userName}`, "");
536
+ if (userRole) lines.push(`**Role:** ${userRole}`, "");
537
+ if (language && language !== "auto") lines.push(`**Language:** ${language}`, "");
406
538
  lines.push(`_Generated by wispy setup on ${new Date().toISOString().slice(0, 10)}_`, "");
407
539
 
408
540
  try {
409
541
  await mkdir(path.join(WISPY_DIR, "memory"), { recursive: true });
410
542
  await writeFile(path.join(WISPY_DIR, "memory", "user.md"), lines.join("\n"), "utf8");
411
- console.log(green(`\n Saved to ~/.wispy/memory/user.md ✅\n`));
543
+ console.log(green(`\n Saved to ~/.wispy/memory/user.md\n`));
412
544
  } catch (err) {
413
545
  console.log(yellow(`\n Could not save memory file: ${err.message}\n`));
414
546
  }
415
547
 
416
- return { _memoryName: name, _memoryRole: role };
548
+ return { language, _memoryName: userName, _memoryRole: userRole };
417
549
  }
418
550
 
419
- // ── Step 6: Security ───────────────────────────────────────────────────────
551
+ // ── Step 7: Security ───────────────────────────────────────────────────────
420
552
 
421
- async stepSecurity(rl) {
422
- console.log(`🔒 ${bold("Security level for tool execution?")}\n`);
423
- console.log(` 1. Careful — ${dim("ask before running commands or modifying files (recommended)")}`);
424
- console.log(` 2. Balanced — ${dim("ask for dangerous commands only")}`);
425
- console.log(` 3. Trust me — ${dim("auto-approve everything (power users)")}`);
426
- console.log("");
553
+ async stepSecurity() {
554
+ console.log(`🔒 ${bold("Security Level")}\n`);
427
555
 
428
- const choice = await askWithDefault(rl, "Choice [1]: ", "1");
429
- const levelMap = { "1": "careful", "2": "balanced", "3": "trust" };
430
- const security = levelMap[choice] || "careful";
431
- const levelInfo = SECURITY_LEVELS[security];
556
+ let security = "careful";
557
+ try {
558
+ security = await select({
559
+ message: "Choose your security level:",
560
+ choices: [
561
+ { name: "Careful — ask before commands/file changes (recommended)", value: "careful" },
562
+ { name: "Balanced — ask for dangerous operations only", value: "balanced" },
563
+ { name: "YOLO — auto-approve everything", value: "yolo" },
564
+ ],
565
+ default: "careful",
566
+ });
567
+ } catch { security = "careful"; }
432
568
 
569
+ const levelInfo = SECURITY_LEVELS[security];
433
570
  console.log(green(`\n ✅ Security: ${levelInfo.label}\n`));
434
571
  return { security, permissions: levelInfo.permissions };
435
572
  }
436
573
 
437
- // ── Step 7: Server / Cloud ─────────────────────────────────────────────────
574
+ // ── Step 8: Server / Cloud ─────────────────────────────────────────────────
438
575
 
439
- async stepServer(rl) {
440
- console.log(`☁️ ${bold("Want to run Wispy as a server?")} ${dim("(access from anywhere)")}\n`);
441
- console.log(" 1. Yes, set up server mode");
442
- console.log(" 2. Generate deployment files (Docker/Railway/Fly)");
443
- console.log(" 3. Connect to existing Wispy server");
444
- console.log(" 4. Skip for now");
445
- console.log("");
576
+ async stepServer() {
577
+ console.log(`☁️ ${bold("Server / Cloud Mode")}\n`);
446
578
 
447
- const choice = await askWithDefault(rl, "Choice [4]: ", "4");
579
+ let mode = "skip";
580
+ try {
581
+ mode = await select({
582
+ message: "How do you want to run Wispy?",
583
+ choices: [
584
+ { name: "Skip for now", value: "skip" },
585
+ { name: "Start server mode (access from anywhere)", value: "server" },
586
+ { name: "Generate deployment files (Docker/Railway)", value: "deploy" },
587
+ { name: "Connect to existing Wispy server", value: "connect" },
588
+ ],
589
+ default: "skip",
590
+ });
591
+ } catch { mode = "skip"; }
448
592
 
449
- if (choice === "4" || choice === "") {
593
+ if (mode === "skip") {
450
594
  console.log(dim(" Skipped. Run 'wispy deploy init' or 'wispy server' later.\n"));
451
595
  return { server: { enabled: false } };
452
596
  }
453
597
 
454
- if (choice === "1") {
455
- const portStr = await askWithDefault(rl, " Port [18790]: ", "18790");
598
+ if (mode === "server") {
599
+ let portStr = "18790";
600
+ let host = "0.0.0.0";
601
+ try {
602
+ portStr = await input({ message: "Port:", default: "18790" });
603
+ host = await input({ message: "Host:", default: "0.0.0.0" });
604
+ } catch { /* defaults */ }
456
605
  const port = parseInt(portStr) || 18790;
457
- const host = await askWithDefault(rl, " Host [0.0.0.0]: ", "0.0.0.0");
458
-
459
- // Generate token
460
606
  const { randomBytes } = await import("node:crypto");
461
607
  const token = randomBytes(24).toString("hex");
462
-
463
608
  console.log(green(`\n ✅ Server mode: ${host}:${port}`));
464
609
  console.log(dim(` Token: ${token}`));
465
610
  console.log(dim(" Start with: wispy server\n"));
466
-
467
611
  return { server: { enabled: true, port, host, token } };
468
612
  }
469
613
 
470
- if (choice === "2") {
614
+ if (mode === "deploy") {
471
615
  console.log(dim("\n Running deploy init...\n"));
472
616
  try {
473
617
  const { DeployManager } = await import("./deploy.mjs");
@@ -481,14 +625,17 @@ export class OnboardingWizard {
481
625
  return { server: { enabled: false } };
482
626
  }
483
627
 
484
- if (choice === "3") {
485
- const url = await ask(rl, " Wispy server URL (e.g. https://my.server:18790): ");
486
- const token = await ask(rl, " Token (optional): ");
628
+ if (mode === "connect") {
629
+ let url = "";
630
+ let token = "";
631
+ try {
632
+ url = await input({ message: "Wispy server URL (e.g. https://my.server:18790):", default: "" });
633
+ token = await password({ message: "Token (optional):", mask: "*" });
634
+ } catch { /* skipped */ }
487
635
  if (url) {
488
636
  try {
489
- const { mkdir: mkdirNode, writeFile: wfNode } = await import("node:fs/promises");
490
- await mkdirNode(WISPY_DIR, { recursive: true });
491
- await wfNode(
637
+ await mkdir(WISPY_DIR, { recursive: true });
638
+ await writeFile(
492
639
  path.join(WISPY_DIR, "remote.json"),
493
640
  JSON.stringify({ url: url.replace(/\/$/, ""), token, connectedAt: new Date().toISOString() }, null, 2),
494
641
  "utf8"
@@ -504,72 +651,33 @@ export class OnboardingWizard {
504
651
  return { server: { enabled: false } };
505
652
  }
506
653
 
507
- // ── Step 8: Summary ────────────────────────────────────────────────────────
508
-
509
- printSummary(config) {
510
- const providerKey = config.provider;
511
- const providerInfo = PROVIDER_LIST.find((p) => p.key === providerKey);
512
- const providerLabel = providerInfo ? providerInfo.label : providerKey ?? dim("none");
513
-
514
- const model = config.model ?? dim("not set");
515
- const workstream = config.workstream ?? "default";
516
- const security = SECURITY_LEVELS[config.security ?? "careful"]?.label ?? "Careful";
517
-
518
- const tg = config.channels?.telegram?.enabled
519
- ? green(`Telegram (@${config.channels.telegram.token?.slice(0, 4) ?? "..."}...)`)
520
- : null;
521
- const dc = config.channels?.discord?.enabled ? green("Discord") : null;
522
- const sl = config.channels?.slack?.enabled ? green("Slack") : null;
523
- const channelsStr = [tg, dc, sl].filter(Boolean).join(", ") || dim("none");
524
-
525
- console.log(`✅ ${bold("Wispy is ready!")}\n`);
526
- console.log(` Provider: ${cyan(providerLabel)} ${dim(`(${model})`)}`);
527
- console.log(` Channels: ${channelsStr}`);
528
- console.log(` Workstream: ${workstream}`);
529
- console.log(` Security: ${security}`);
530
- console.log(` Memory: ${dim("user.md saved")}`);
531
- console.log(` Config: ${dim("~/.wispy/config.json")}`);
532
- console.log("");
533
- console.log("Getting started:");
534
- console.log(` ${cyan("wispy")} Interactive chat`);
535
- console.log(` ${cyan("wispy --tui")} Full terminal UI`);
536
- console.log(` ${cyan("wispy --serve")} Start channels + cron`);
537
- console.log(` ${cyan('wispy "hello"')} Quick one-shot`);
538
- console.log("");
539
- console.log("Happy building! 🌿");
540
- console.log("");
541
- }
542
-
543
654
  // ── Public: run full wizard ────────────────────────────────────────────────
544
655
 
545
656
  async run() {
546
- const rl = makeRl();
547
-
548
- // Trap Ctrl+C gracefully
549
- rl.on("SIGINT", () => {
657
+ // Graceful Ctrl+C handler
658
+ process.on("SIGINT", () => {
550
659
  console.log(dim("\n\n Setup skipped. Run 'wispy setup' anytime.\n"));
551
660
  process.exit(0);
552
661
  });
553
662
 
554
663
  try {
555
- await this.stepWelcome(rl);
556
-
557
- const providerResult = await this.stepProvider(rl);
558
- const channelsResult = await this.stepChannels(rl);
559
- const workstreamResult = await this.stepWorkstream(rl);
560
- const memoryResult = await this.stepMemory(rl);
561
- const securityResult = await this.stepSecurity(rl);
562
- const serverResult = await this.stepServer(rl);
664
+ await this.stepWelcome();
563
665
 
564
- rl.close();
666
+ const providerResult = await this.stepProvider();
667
+ const channelsResult = await this.stepChannels();
668
+ const workstreamResult = await this.stepWorkstream();
669
+ const memoryResult = await this.stepMemory();
670
+ const securityResult = await this.stepSecurity();
671
+ const serverResult = await this.stepServer();
565
672
 
566
- // Merge everything into config
673
+ // Merge everything
567
674
  const existingConfig = await loadConfig();
568
675
  const config = {
569
676
  ...existingConfig,
570
677
  ...providerResult,
571
678
  ...channelsResult,
572
679
  ...workstreamResult,
680
+ ...memoryResult,
573
681
  ...securityResult,
574
682
  ...serverResult,
575
683
  onboarded: true,
@@ -581,12 +689,15 @@ export class OnboardingWizard {
581
689
  delete config._memoryRole;
582
690
 
583
691
  await saveConfig(config);
584
- console.log("");
585
- this.printSummary(config);
692
+ printSummaryBox(config);
586
693
 
587
694
  return config;
588
695
  } catch (err) {
589
- rl.close();
696
+ // ExitPromptError = user pressed Ctrl+C inside a prompt
697
+ if (err?.name === "ExitPromptError" || err?.code === "ERR_USE_AFTER_CLOSE") {
698
+ console.log(dim("\n\n Setup skipped. Run 'wispy setup' anytime.\n"));
699
+ process.exit(0);
700
+ }
590
701
  throw err;
591
702
  }
592
703
  }
@@ -594,35 +705,31 @@ export class OnboardingWizard {
594
705
  // ── Public: run individual step ────────────────────────────────────────────
595
706
 
596
707
  async runStep(step) {
597
- const rl = makeRl();
598
- rl.on("SIGINT", () => {
708
+ process.on("SIGINT", () => {
599
709
  console.log(dim("\n Cancelled.\n"));
600
- rl.close();
601
710
  process.exit(0);
602
711
  });
603
712
 
604
- let partial = {};
605
- try {
606
- const stepMap = {
607
- welcome: () => this.stepWelcome(rl),
608
- provider: () => this.stepProvider(rl),
609
- channels: () => this.stepChannels(rl),
610
- workstream: () => this.stepWorkstream(rl),
611
- memory: () => this.stepMemory(rl),
612
- security: () => this.stepSecurity(rl),
613
- server: () => this.stepServer(rl),
614
- };
713
+ const stepMap = {
714
+ welcome: () => this.stepWelcome(),
715
+ provider: () => this.stepProvider(),
716
+ channels: () => this.stepChannels(),
717
+ workstream: () => this.stepWorkstream(),
718
+ memory: () => this.stepMemory(),
719
+ security: () => this.stepSecurity(),
720
+ server: () => this.stepServer(),
721
+ };
615
722
 
616
- if (!stepMap[step]) {
617
- console.log(red(` Unknown step: ${step}`));
618
- console.log(dim(" Valid steps: provider, channels, workstream, memory, security, server"));
619
- rl.close();
620
- return {};
621
- }
723
+ if (!stepMap[step]) {
724
+ console.log(red(` Unknown step: ${step}`));
725
+ console.log(dim(" Valid steps: provider, channels, workstream, memory, security, server"));
726
+ return {};
727
+ }
622
728
 
729
+ let partial = {};
730
+ try {
623
731
  partial = await stepMap[step]();
624
732
 
625
- // Merge with existing config and save
626
733
  if (Object.keys(partial).length > 0) {
627
734
  const existing = await loadConfig();
628
735
  const updated = { ...existing, ...partial };
@@ -631,8 +738,12 @@ export class OnboardingWizard {
631
738
  await saveConfig(updated);
632
739
  console.log(green(" ✅ Config saved.\n"));
633
740
  }
634
- } finally {
635
- rl.close();
741
+ } catch (err) {
742
+ if (err?.name === "ExitPromptError" || err?.code === "ERR_USE_AFTER_CLOSE") {
743
+ console.log(dim("\n Cancelled.\n"));
744
+ return {};
745
+ }
746
+ throw err;
636
747
  }
637
748
 
638
749
  return partial;
@@ -659,9 +770,7 @@ export async function isFirstRun() {
659
770
  * Print `wispy status` output — comprehensive current config view
660
771
  */
661
772
  export async function printStatus() {
662
- const { readdir, stat } = await import("node:fs/promises");
663
- const { existsSync } = await import("node:fs");
664
- const { SESSIONS_DIR, MEMORY_DIR: MEM_DIR } = await import("./config.mjs");
773
+ const { readdir } = await import("node:fs/promises");
665
774
 
666
775
  const config = await loadConfig();
667
776
 
@@ -679,7 +788,6 @@ export async function printStatus() {
679
788
  const mFiles = await readdir(path.join(WISPY_DIR, "memory"));
680
789
  memFiles = mFiles.filter((f) => f.endsWith(".md") || f.endsWith(".txt"));
681
790
  memCount = memFiles.length;
682
- // Count daily files
683
791
  try {
684
792
  const dailyFiles = await readdir(path.join(WISPY_DIR, "memory", "daily"));
685
793
  memCount += dailyFiles.length;
@@ -708,27 +816,34 @@ export async function printStatus() {
708
816
  nodeCount = (nodeData.nodes ?? []).length;
709
817
  } catch {}
710
818
 
711
- // Provider info
712
- const providerKey = config.provider;
713
- const providerInfo = PROVIDER_LIST.find((p) => p.key === providerKey);
714
- const providerLabel = providerInfo ? providerInfo.label : providerKey ?? dim("not set");
715
- const hasApiKey = !!(config.apiKey || (providerKey === "ollama"));
716
- const providerDisplay = providerKey
717
- ? `${cyan(providerLabel)} ${dim(`(${config.model ?? providerInfo?.defaultModel ?? "?"})`)}`
718
- + (hasApiKey ? ` ${green("")}` : ` ${yellow("⚠️ no key")}`)
719
- : red("not configured");
819
+ // Provider info (support both old single-provider and new multi-provider config)
820
+ let providerDisplay;
821
+ if (config.providers && Object.keys(config.providers).length > 0) {
822
+ const provKeys = Object.keys(config.providers);
823
+ const defaultP = config.defaultProvider ?? provKeys[0];
824
+ const defaultModel = config.providers[defaultP]?.model ?? getProviderDefaultModel(defaultP);
825
+ const names = provKeys.map((k) => `${getProviderLabel(k).split(" —")[0]} ${green("✅")}`).join(", ");
826
+ providerDisplay = `${names}\n Default: ${cyan(getProviderLabel(defaultP).split("")[0])} ${dim(`(${defaultModel})`)}`;
827
+ } else if (config.provider) {
828
+ const model = config.model ?? getProviderDefaultModel(config.provider);
829
+ const label = getProviderLabel(config.provider);
830
+ const hasKey = !!(config.apiKey || config.provider === "ollama");
831
+ providerDisplay = `${cyan(label)} ${dim(`(${model})`)}` + (hasKey ? ` ${green("✅")}` : ` ${yellow("⚠️ no key")}`);
832
+ } else {
833
+ providerDisplay = red("not configured");
834
+ }
720
835
 
721
836
  // Channels
722
- const tgOk = config.channels?.telegram?.enabled;
723
- const dcOk = config.channels?.discord?.enabled;
724
- const slOk = config.channels?.slack?.enabled;
837
+ const tgOk = config.channels?.telegram?.enabled;
838
+ const dcOk = config.channels?.discord?.enabled;
839
+ const slOk = config.channels?.slack?.enabled;
725
840
  const chanStr = [
726
841
  `Telegram ${tgOk ? green("✅") : red("❌")}`,
727
- `Discord ${dcOk ? green("✅") : red("❌")}`,
728
- `Slack ${slOk ? green("✅") : red("❌")}`,
842
+ `Discord ${dcOk ? green("✅") : red("❌")}`,
843
+ `Slack ${slOk ? green("✅") : red("❌")}`,
729
844
  ].join(dim(" | "));
730
845
 
731
- const mode = remote?.url ? yellow("remote") : green("local");
846
+ const mode = remote?.url ? yellow("remote") : green("local");
732
847
  const security = SECURITY_LEVELS[config.security ?? "careful"]?.label ?? "Careful";
733
848
 
734
849
  console.log(`\n🌿 ${bold("Wispy Status")}\n`);
@@ -738,12 +853,13 @@ export async function printStatus() {
738
853
  console.log(` Channels: ${chanStr}`);
739
854
  console.log(` Workstream: ${config.workstream ?? "default"}`);
740
855
  console.log(` Security: ${security}`);
856
+ console.log(` Language: ${config.language ?? "auto"}`);
741
857
  console.log(` Memory: ${memCount} file${memCount !== 1 ? "s" : ""}`
742
858
  + (memFiles.length > 0 ? dim(` (${memFiles.slice(0, 3).join(", ")}${memFiles.length > 3 ? "..." : ""})`) : ""));
743
859
  console.log(` Sessions: ${sessionCount} total`);
744
860
  console.log(` Cron jobs: ${cronCount} active`);
745
861
  console.log(` Nodes: ${nodeCount} connected`);
746
- console.log(` Server: ${config.server?.enabled ? green("On :"+config.server.port) : "Off"}`);
862
+ console.log(` Server: ${config.server?.enabled ? green("On :" + config.server.port) : "Off"}`);
747
863
  console.log("");
748
864
  console.log(` ${dim("Config:")} ${dim(CONFIG_PATH)}`);
749
865
  console.log(` ${dim("Data: ")} ${dim(WISPY_DIR + "/")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",
@@ -57,12 +57,12 @@
57
57
  "LICENSE"
58
58
  ],
59
59
  "scripts": {
60
- "postinstall": "node scripts/postinstall.mjs",
61
60
  "test": "node --test --test-force-exit --test-timeout=15000 tests/*.test.mjs",
62
61
  "test:basic": "node --test --test-force-exit test/basic.test.mjs",
63
62
  "test:verbose": "node --test --test-force-exit --test-timeout=15000 --test-reporter=spec tests/*.test.mjs"
64
63
  },
65
64
  "dependencies": {
65
+ "@inquirer/prompts": "^8.3.2",
66
66
  "cron-parser": "^5.5.0",
67
67
  "ink": "^5.2.1",
68
68
  "ink-spinner": "^5.0.0",
@@ -74,10 +74,10 @@
74
74
  "@slack/bolt": ">=3.0.0",
75
75
  "discord.js": ">=14.0.0",
76
76
  "grammy": ">=1.0.0",
77
- "whatsapp-web.js": ">=1.0.0",
78
- "qrcode-terminal": ">=0.12.0",
77
+ "imapflow": ">=1.0.0",
79
78
  "nodemailer": ">=6.0.0",
80
- "imapflow": ">=1.0.0"
79
+ "qrcode-terminal": ">=0.12.0",
80
+ "whatsapp-web.js": ">=1.0.0"
81
81
  },
82
82
  "peerDependenciesMeta": {
83
83
  "grammy": {