wispy-cli 1.2.2 → 1.2.3

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/bin/wispy.mjs CHANGED
@@ -22,8 +22,33 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
22
 
23
23
  const args = process.argv.slice(2);
24
24
 
25
+ // ── setup / init sub-command ──────────────────────────────────────────────────
26
+ if (args[0] === "setup" || args[0] === "init") {
27
+ const { OnboardingWizard } = await import(
28
+ path.join(__dirname, "..", "core", "onboarding.mjs")
29
+ );
30
+ const wizard = new OnboardingWizard();
31
+ const sub = args[1]; // e.g. "provider", "channels", "security"
32
+ if (sub && sub !== "wizard") {
33
+ await wizard.runStep(sub);
34
+ } else {
35
+ await wizard.run();
36
+ }
37
+ process.exit(0);
38
+ }
39
+
25
40
  // ── status sub-command ────────────────────────────────────────────────────────
26
41
  if (args[0] === "status") {
42
+ // Try the enhanced status from onboarding.mjs first
43
+ try {
44
+ const { printStatus } = await import(
45
+ path.join(__dirname, "..", "core", "onboarding.mjs")
46
+ );
47
+ await printStatus();
48
+ process.exit(0);
49
+ } catch {}
50
+
51
+ // Fallback: original status (remote check)
27
52
  const { readFile } = await import("node:fs/promises");
28
53
  const { homedir } = await import("node:os");
29
54
  const { join } = await import("node:path");
@@ -775,6 +800,32 @@ if (serveMode || telegramMode || discordMode || slackMode) {
775
800
  await new Promise(() => {}); // keep alive
776
801
  }
777
802
 
803
+ // ── First-run detection (before TUI or REPL) ──────────────────────────────────
804
+ // Only trigger onboarding for interactive modes (not flags like --serve, channel, etc.)
805
+ const isInteractiveStart = !args.some(a =>
806
+ ["--serve", "--telegram", "--discord", "--slack", "--server",
807
+ "status", "setup", "init", "connect", "disconnect", "deploy",
808
+ "cron", "audit", "log", "server", "node", "channel"].includes(a)
809
+ );
810
+
811
+ if (isInteractiveStart) {
812
+ try {
813
+ const { isFirstRun } = await import(
814
+ path.join(__dirname, "..", "core", "config.mjs")
815
+ );
816
+ if (await isFirstRun()) {
817
+ const { OnboardingWizard } = await import(
818
+ path.join(__dirname, "..", "core", "onboarding.mjs")
819
+ );
820
+ const wizard = new OnboardingWizard();
821
+ await wizard.run();
822
+ // After onboarding, continue to REPL or TUI
823
+ }
824
+ } catch {
825
+ // If onboarding fails for any reason, continue normally
826
+ }
827
+ }
828
+
778
829
  // ── TUI mode ──────────────────────────────────────────────────────────────────
779
830
  const tuiMode = args.includes("--tui");
780
831
 
package/core/config.mjs CHANGED
@@ -58,6 +58,18 @@ export async function saveConfig(config) {
58
58
  await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
59
59
  }
60
60
 
61
+ /**
62
+ * Returns true if no config exists or onboarded flag is not set.
63
+ */
64
+ export async function isFirstRun() {
65
+ try {
66
+ const cfg = await loadConfig();
67
+ return !cfg.onboarded;
68
+ } catch {
69
+ return true;
70
+ }
71
+ }
72
+
61
73
  export async function detectProvider() {
62
74
  // 1. WISPY_PROVIDER env override
63
75
  const forced = process.env.WISPY_PROVIDER;
package/core/index.mjs CHANGED
@@ -9,7 +9,8 @@ export { SessionManager, Session, sessionManager } from "./session.mjs";
9
9
  export { ProviderRegistry } from "./providers.mjs";
10
10
  export { ToolRegistry } from "./tools.mjs";
11
11
  export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
12
- export { loadConfig, saveConfig, detectProvider, PROVIDERS, WISPY_DIR, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
12
+ export { loadConfig, saveConfig, detectProvider, isFirstRun, PROVIDERS, WISPY_DIR, CONFIG_PATH, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
13
+ export { OnboardingWizard, isFirstRun as checkFirstRun, printStatus } from "./onboarding.mjs";
13
14
  export { MemoryManager } from "./memory.mjs";
14
15
  export { CronManager } from "./cron.mjs";
15
16
  export { SubAgentManager, SubAgent } from "./subagents.mjs";
@@ -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
+ }
@@ -923,7 +923,15 @@ const engine = new WispyEngine({ workstream: ACTIVE_WORKSTREAM });
923
923
  const initResult = await engine.init();
924
924
 
925
925
  if (!initResult) {
926
- await runOnboarding();
926
+ // Delegate to unified onboarding wizard
927
+ try {
928
+ const { OnboardingWizard } = await import("../core/onboarding.mjs");
929
+ const wizard = new OnboardingWizard();
930
+ await wizard.run();
931
+ } catch {
932
+ // Fallback to legacy inline onboarding
933
+ await runOnboarding();
934
+ }
927
935
  // Try again after onboarding
928
936
  const initResult2 = await engine.init();
929
937
  if (!initResult2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",