wispy-cli 1.2.2 → 1.3.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.
@@ -0,0 +1,751 @@
1
+ /**
2
+ * core/onboarding.mjs — Unified first-run wizard for Wispy
3
+ *
4
+ * Handles interactive setup from scratch:
5
+ * - AI provider + API key
6
+ * - Messaging channels (Telegram, Discord, Slack)
7
+ * - Workstream
8
+ * - Memory / personalization
9
+ * - Security level
10
+ * - Server / cloud mode
11
+ *
12
+ * Usage:
13
+ * import { OnboardingWizard } from './onboarding.mjs';
14
+ * const wizard = new OnboardingWizard();
15
+ * const config = await wizard.run(); // full wizard
16
+ * const partial = await wizard.runStep('provider'); // single step
17
+ */
18
+
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { createInterface } from "node:readline";
22
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
23
+
24
+ import {
25
+ WISPY_DIR,
26
+ CONFIG_PATH,
27
+ MEMORY_DIR,
28
+ PROVIDERS,
29
+ loadConfig,
30
+ saveConfig,
31
+ } from "./config.mjs";
32
+
33
+ // ──────────────────────────────────────────────────────────────────────────────
34
+ // ANSI helpers (no deps)
35
+ // ──────────────────────────────────────────────────────────────────────────────
36
+
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
+
44
+ // ──────────────────────────────────────────────────────────────────────────────
45
+ // Provider registry (display order for wizard)
46
+ // ──────────────────────────────────────────────────────────────────────────────
47
+
48
+ 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 },
57
+ ];
58
+
59
+ // ──────────────────────────────────────────────────────────────────────────────
60
+ // Security level definitions
61
+ // ──────────────────────────────────────────────────────────────────────────────
62
+
63
+ 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
+ },
79
+ };
80
+
81
+ // ──────────────────────────────────────────────────────────────────────────────
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)
100
+ // ──────────────────────────────────────────────────────────────────────────────
101
+
102
+ async function validateApiKey(provider, key) {
103
+ if (provider === "ollama") return { ok: true, model: "llama3.2" };
104
+ try {
105
+ const info = PROVIDERS[provider];
106
+ if (!info) return { ok: false, error: "Unknown provider" };
107
+
108
+ 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}` };
113
+ }
114
+
115
+ if (provider === "anthropic") {
116
+ const r = await fetch("https://api.anthropic.com/v1/models", {
117
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
118
+ signal: AbortSignal.timeout(6000),
119
+ });
120
+ if (r.ok) return { ok: true, model: "claude-sonnet-4-20250514" };
121
+ return { ok: false, error: `HTTP ${r.status}` };
122
+ }
123
+
124
+ if (provider === "openai") {
125
+ const r = await fetch("https://api.openai.com/v1/models", {
126
+ headers: { Authorization: `Bearer ${key}` },
127
+ signal: AbortSignal.timeout(6000),
128
+ });
129
+ if (r.ok) return { ok: true, model: "gpt-4o" };
130
+ return { ok: false, error: `HTTP ${r.status}` };
131
+ }
132
+
133
+ // For other providers just trust the key format
134
+ return { ok: true, model: info.defaultModel };
135
+ } catch (err) {
136
+ return { ok: false, error: err.message };
137
+ }
138
+ }
139
+
140
+ // ──────────────────────────────────────────────────────────────────────────────
141
+ // Save API key to shell env file
142
+ // ──────────────────────────────────────────────────────────────────────────────
143
+
144
+ async function saveKeyToEnvFile(provider, key, filePath) {
145
+ const info = PROVIDERS[provider];
146
+ if (!info || !info.envKeys?.[0]) return;
147
+ const envVar = info.envKeys[0];
148
+ const line = `\nexport ${envVar}="${key}" # wispy-cli\n`;
149
+ try {
150
+ const existing = await readFile(filePath, "utf8").catch(() => "");
151
+ // Remove old entry if present
152
+ const filtered = existing
153
+ .split("\n")
154
+ .filter((l) => !l.includes(`${envVar}=`) || !l.includes("wispy-cli"))
155
+ .join("\n");
156
+ await writeFile(filePath, filtered + line, "utf8");
157
+ } catch (err) {
158
+ console.error(red(` Failed to write to ${filePath}: ${err.message}`));
159
+ }
160
+ }
161
+
162
+ // ──────────────────────────────────────────────────────────────────────────────
163
+ // Channel validation (just ping the token)
164
+ // ──────────────────────────────────────────────────────────────────────────────
165
+
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 }; }
175
+ }
176
+
177
+ // ──────────────────────────────────────────────────────────────────────────────
178
+ // OnboardingWizard class
179
+ // ──────────────────────────────────────────────────────────────────────────────
180
+
181
+ export class OnboardingWizard {
182
+ constructor() {
183
+ this._config = {};
184
+ }
185
+
186
+ // ── Step 1: Welcome ────────────────────────────────────────────────────────
187
+
188
+ async stepWelcome(rl) {
189
+ console.log("");
190
+ console.log(`🌿 ${bold("Welcome to Wispy")} — your personal AI assistant`);
191
+ 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
+ }
197
+
198
+ // ── Step 2: AI Provider ────────────────────────────────────────────────────
199
+
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("");
207
+
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];
211
+
212
+ if (!selected.key) {
213
+ console.log(dim("\n OK — run 'wispy setup provider' later to configure.\n"));
214
+ return {};
215
+ }
216
+
217
+ let apiKey = null;
218
+
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" };
226
+ }
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
+
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("");
238
+
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."));
257
+ }
258
+ }
259
+
260
+ if (!validated) {
261
+ console.log(dim("\n Skipping provider setup. Run 'wispy setup provider' later.\n"));
262
+ return {};
263
+ }
264
+
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`));
277
+ }
278
+
279
+ const storeInConfig = saveChoice !== "2"; // save in config unless env-only
280
+
281
+ const model = selected.defaultModel;
282
+ console.log("");
283
+
284
+ return {
285
+ provider: selected.key,
286
+ model,
287
+ ...(storeInConfig ? { apiKey } : {}),
288
+ };
289
+ }
290
+
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("");
300
+
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));
303
+
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 };
313
+ }
314
+
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:");
320
+ console.log("");
321
+
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
+ }
333
+ }
334
+ }
335
+
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("");
342
+
343
+ const token = await ask(rl, " Token: ");
344
+ if (token) {
345
+ channels.discord = { enabled: true, token };
346
+ console.log(green(" ✅ Token saved"));
347
+ }
348
+ }
349
+
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("");
356
+
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"));
362
+ }
363
+ }
364
+
365
+ console.log("");
366
+ return { channels };
367
+ }
368
+
369
+ // ── Step 4: Workstream ─────────────────────────────────────────────────────
370
+
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");
379
+
380
+ if (choice !== "1") {
381
+ console.log(dim(" Using default workstream.\n"));
382
+ return { workstream: "default" };
383
+ }
384
+
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";
387
+
388
+ console.log(green(` ✅ Workstream "${clean}" set.\n`));
389
+ return { workstream: clean };
390
+ }
391
+
392
+ // ── Step 5: Memory ─────────────────────────────────────────────────────────
393
+
394
+ async stepMemory(rl) {
395
+ console.log(`🧠 ${bold("Tell Wispy about yourself")} ${dim("(helps personalize responses)")}\n`);
396
+
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]: ", "");
400
+
401
+ // Build user.md
402
+ 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}`, "");
406
+ lines.push(`_Generated by wispy setup on ${new Date().toISOString().slice(0, 10)}_`, "");
407
+
408
+ try {
409
+ await mkdir(path.join(WISPY_DIR, "memory"), { recursive: true });
410
+ 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`));
412
+ } catch (err) {
413
+ console.log(yellow(`\n Could not save memory file: ${err.message}\n`));
414
+ }
415
+
416
+ return { _memoryName: name, _memoryRole: role };
417
+ }
418
+
419
+ // ── Step 6: Security ───────────────────────────────────────────────────────
420
+
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("");
427
+
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];
432
+
433
+ console.log(green(`\n ✅ Security: ${levelInfo.label}\n`));
434
+ return { security, permissions: levelInfo.permissions };
435
+ }
436
+
437
+ // ── Step 7: Server / Cloud ─────────────────────────────────────────────────
438
+
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("");
446
+
447
+ const choice = await askWithDefault(rl, "Choice [4]: ", "4");
448
+
449
+ if (choice === "4" || choice === "") {
450
+ console.log(dim(" Skipped. Run 'wispy deploy init' or 'wispy server' later.\n"));
451
+ return { server: { enabled: false } };
452
+ }
453
+
454
+ if (choice === "1") {
455
+ const portStr = await askWithDefault(rl, " Port [18790]: ", "18790");
456
+ 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
+ const { randomBytes } = await import("node:crypto");
461
+ const token = randomBytes(24).toString("hex");
462
+
463
+ console.log(green(`\n ✅ Server mode: ${host}:${port}`));
464
+ console.log(dim(` Token: ${token}`));
465
+ console.log(dim(" Start with: wispy server\n"));
466
+
467
+ return { server: { enabled: true, port, host, token } };
468
+ }
469
+
470
+ if (choice === "2") {
471
+ console.log(dim("\n Running deploy init...\n"));
472
+ try {
473
+ const { DeployManager } = await import("./deploy.mjs");
474
+ const dm = new DeployManager();
475
+ const created = await dm.init(process.cwd());
476
+ for (const f of created) console.log(` ✅ ${f}`);
477
+ } catch (err) {
478
+ console.log(yellow(` Could not run deploy init: ${err.message}`));
479
+ }
480
+ console.log("");
481
+ return { server: { enabled: false } };
482
+ }
483
+
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): ");
487
+ if (url) {
488
+ try {
489
+ const { mkdir: mkdirNode, writeFile: wfNode } = await import("node:fs/promises");
490
+ await mkdirNode(WISPY_DIR, { recursive: true });
491
+ await wfNode(
492
+ path.join(WISPY_DIR, "remote.json"),
493
+ JSON.stringify({ url: url.replace(/\/$/, ""), token, connectedAt: new Date().toISOString() }, null, 2),
494
+ "utf8"
495
+ );
496
+ console.log(green(`\n ✅ Connected to ${url}\n`));
497
+ } catch (err) {
498
+ console.log(yellow(`\n Warning: ${err.message}\n`));
499
+ }
500
+ }
501
+ return { server: { enabled: false, remote: url } };
502
+ }
503
+
504
+ return { server: { enabled: false } };
505
+ }
506
+
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
+ // ── Public: run full wizard ────────────────────────────────────────────────
544
+
545
+ async run() {
546
+ const rl = makeRl();
547
+
548
+ // Trap Ctrl+C gracefully
549
+ rl.on("SIGINT", () => {
550
+ console.log(dim("\n\n Setup skipped. Run 'wispy setup' anytime.\n"));
551
+ process.exit(0);
552
+ });
553
+
554
+ 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);
563
+
564
+ rl.close();
565
+
566
+ // Merge everything into config
567
+ const existingConfig = await loadConfig();
568
+ const config = {
569
+ ...existingConfig,
570
+ ...providerResult,
571
+ ...channelsResult,
572
+ ...workstreamResult,
573
+ ...securityResult,
574
+ ...serverResult,
575
+ onboarded: true,
576
+ onboardedAt: new Date().toISOString(),
577
+ };
578
+
579
+ // Remove internal step fields
580
+ delete config._memoryName;
581
+ delete config._memoryRole;
582
+
583
+ await saveConfig(config);
584
+ console.log("");
585
+ this.printSummary(config);
586
+
587
+ return config;
588
+ } catch (err) {
589
+ rl.close();
590
+ throw err;
591
+ }
592
+ }
593
+
594
+ // ── Public: run individual step ────────────────────────────────────────────
595
+
596
+ async runStep(step) {
597
+ const rl = makeRl();
598
+ rl.on("SIGINT", () => {
599
+ console.log(dim("\n Cancelled.\n"));
600
+ rl.close();
601
+ process.exit(0);
602
+ });
603
+
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
+ };
615
+
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
+ }
622
+
623
+ partial = await stepMap[step]();
624
+
625
+ // Merge with existing config and save
626
+ if (Object.keys(partial).length > 0) {
627
+ const existing = await loadConfig();
628
+ const updated = { ...existing, ...partial };
629
+ delete updated._memoryName;
630
+ delete updated._memoryRole;
631
+ await saveConfig(updated);
632
+ console.log(green(" ✅ Config saved.\n"));
633
+ }
634
+ } finally {
635
+ rl.close();
636
+ }
637
+
638
+ return partial;
639
+ }
640
+ }
641
+
642
+ // ──────────────────────────────────────────────────────────────────────────────
643
+ // Convenience helpers
644
+ // ──────────────────────────────────────────────────────────────────────────────
645
+
646
+ /**
647
+ * Check if this is a first run (config missing or onboarded !== true)
648
+ */
649
+ export async function isFirstRun() {
650
+ try {
651
+ const config = await loadConfig();
652
+ return !config.onboarded;
653
+ } catch {
654
+ return true;
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Print `wispy status` output — comprehensive current config view
660
+ */
661
+ 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");
665
+
666
+ const config = await loadConfig();
667
+
668
+ // Remote mode?
669
+ let remote = null;
670
+ try {
671
+ const rp = path.join(WISPY_DIR, "remote.json");
672
+ remote = JSON.parse(await readFile(rp, "utf8"));
673
+ } catch {}
674
+
675
+ // Count memory files
676
+ let memCount = 0;
677
+ let memFiles = [];
678
+ try {
679
+ const mFiles = await readdir(path.join(WISPY_DIR, "memory"));
680
+ memFiles = mFiles.filter((f) => f.endsWith(".md") || f.endsWith(".txt"));
681
+ memCount = memFiles.length;
682
+ // Count daily files
683
+ try {
684
+ const dailyFiles = await readdir(path.join(WISPY_DIR, "memory", "daily"));
685
+ memCount += dailyFiles.length;
686
+ memFiles.push(...dailyFiles.map((f) => `daily/${f}`));
687
+ } catch {}
688
+ } catch {}
689
+
690
+ // Count sessions
691
+ let sessionCount = 0;
692
+ try {
693
+ const sFiles = await readdir(path.join(WISPY_DIR, "sessions"));
694
+ sessionCount = sFiles.filter((f) => f.endsWith(".json")).length;
695
+ } catch {}
696
+
697
+ // Count cron jobs
698
+ let cronCount = 0;
699
+ try {
700
+ const cronData = JSON.parse(await readFile(path.join(WISPY_DIR, "cron.json"), "utf8"));
701
+ cronCount = (cronData.jobs ?? []).filter((j) => j.enabled).length;
702
+ } catch {}
703
+
704
+ // Count nodes
705
+ let nodeCount = 0;
706
+ try {
707
+ const nodeData = JSON.parse(await readFile(path.join(WISPY_DIR, "nodes.json"), "utf8"));
708
+ nodeCount = (nodeData.nodes ?? []).length;
709
+ } catch {}
710
+
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");
720
+
721
+ // Channels
722
+ const tgOk = config.channels?.telegram?.enabled;
723
+ const dcOk = config.channels?.discord?.enabled;
724
+ const slOk = config.channels?.slack?.enabled;
725
+ const chanStr = [
726
+ `Telegram ${tgOk ? green("✅") : red("❌")}`,
727
+ `Discord ${dcOk ? green("✅") : red("❌")}`,
728
+ `Slack ${slOk ? green("✅") : red("❌")}`,
729
+ ].join(dim(" | "));
730
+
731
+ const mode = remote?.url ? yellow("remote") : green("local");
732
+ const security = SECURITY_LEVELS[config.security ?? "careful"]?.label ?? "Careful";
733
+
734
+ console.log(`\n🌿 ${bold("Wispy Status")}\n`);
735
+ console.log(` Mode: ${mode}`);
736
+ if (remote?.url) console.log(` Server: ${cyan(remote.url)}`);
737
+ console.log(` Provider: ${providerDisplay}`);
738
+ console.log(` Channels: ${chanStr}`);
739
+ console.log(` Workstream: ${config.workstream ?? "default"}`);
740
+ console.log(` Security: ${security}`);
741
+ console.log(` Memory: ${memCount} file${memCount !== 1 ? "s" : ""}`
742
+ + (memFiles.length > 0 ? dim(` (${memFiles.slice(0, 3).join(", ")}${memFiles.length > 3 ? "..." : ""})`) : ""));
743
+ console.log(` Sessions: ${sessionCount} total`);
744
+ console.log(` Cron jobs: ${cronCount} active`);
745
+ console.log(` Nodes: ${nodeCount} connected`);
746
+ console.log(` Server: ${config.server?.enabled ? green("On :"+config.server.port) : "Off"}`);
747
+ console.log("");
748
+ console.log(` ${dim("Config:")} ${dim(CONFIG_PATH)}`);
749
+ console.log(` ${dim("Data: ")} ${dim(WISPY_DIR + "/")}`);
750
+ console.log("");
751
+ }