wispy-cli 2.2.1 → 2.3.1

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
@@ -18,73 +18,590 @@
18
18
 
19
19
  import { fileURLToPath } from "node:url";
20
20
  import path from "node:path";
21
+ import { readFileSync } from "node:fs";
21
22
 
22
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
24
 
24
25
  const args = process.argv.slice(2);
25
26
 
27
+ // ── Global helpers ─────────────────────────────────────────────────────────────
28
+
29
+ // Color helpers (reused throughout)
30
+ const _green = (s) => `\x1b[32m${s}\x1b[0m`;
31
+ const _red = (s) => `\x1b[31m${s}\x1b[0m`;
32
+ const _yellow = (s) => `\x1b[33m${s}\x1b[0m`;
33
+ const _cyan = (s) => `\x1b[36m${s}\x1b[0m`;
34
+ const _bold = (s) => `\x1b[1m${s}\x1b[0m`;
35
+ const _dim = (s) => `\x1b[2m${s}\x1b[0m`;
36
+
37
+ // Debug mode (--debug flag or WISPY_DEBUG env)
38
+ const DEBUG = args.includes("--debug") || process.env.WISPY_DEBUG === "1";
39
+ if (DEBUG) {
40
+ process.env.WISPY_DEBUG = "1";
41
+ // Remove --debug from args so it doesn't confuse subcommands
42
+ const idx = args.indexOf("--debug");
43
+ if (idx !== -1) args.splice(idx, 1);
44
+ }
45
+
46
+ // Friendly error display
47
+ function friendlyError(err, exitCode = 1) {
48
+ if (DEBUG) {
49
+ console.error(_red(`\n❌ Error: ${err.message}`));
50
+ console.error(_dim(err.stack));
51
+ } else {
52
+ console.error(_red(`\n❌ Error: ${err.message}`));
53
+ console.error(_dim(" Run with --debug for more details."));
54
+ }
55
+ process.exit(exitCode);
56
+ }
57
+
58
+ // Read version once
59
+ let _version;
60
+ function getVersion() {
61
+ if (_version) return _version;
62
+ try {
63
+ _version = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version;
64
+ } catch { _version = "?.?.?"; }
65
+ return _version;
66
+ }
67
+
68
+ // Global SIGINT handler (individual commands may override this)
69
+ process.on("SIGINT", () => {
70
+ console.log(_dim("\n\nInterrupted."));
71
+ process.exit(130);
72
+ });
73
+
74
+ // ── --version / -v / version ───────────────────────────────────────────────────
75
+ if (args[0] === "--version" || args[0] === "-v" || args[0] === "version") {
76
+ console.log(`wispy-cli v${getVersion()}`);
77
+ process.exit(0);
78
+ }
79
+
80
+ // ── --help / -h ────────────────────────────────────────────────────────────────
81
+ if (args[0] === "--help" || args[0] === "-h") {
82
+ const v = getVersion();
83
+ console.log(`
84
+ ${_bold("🌿 Wispy")} ${_dim(`v${v}`)} — AI workspace assistant
85
+
86
+ ${_bold("Usage:")} wispy [command] [options]
87
+
88
+ ${_bold("AI Interaction:")}
89
+ ${_cyan("wispy")} Start interactive REPL
90
+ ${_cyan('wispy "message"')} One-shot message
91
+ ${_cyan("wispy tui")} Workspace TUI
92
+ ${_cyan("wispy dry <cmd>")} Run in dry-run mode
93
+
94
+ ${_bold("Workstreams:")}
95
+ ${_cyan("wispy ws")} List workstreams
96
+ ${_cyan("wispy ws new <name>")} Create workstream
97
+ ${_cyan("wispy ws switch <name>")} Switch workstream
98
+ ${_cyan("wispy ws archive <name>")} Archive workstream
99
+ ${_cyan("wispy ws delete <name>")} Delete workstream
100
+
101
+ ${_bold("Trust & Security:")}
102
+ ${_cyan("wispy trust")} Show trust level & policies
103
+ ${_cyan("wispy trust <level>")} Set trust (careful/balanced/yolo)
104
+ ${_cyan("wispy audit")} View audit log
105
+
106
+ ${_bold("Continuity:")}
107
+ ${_cyan("wispy where")} Show current mode
108
+ ${_cyan("wispy handoff")} Generate handoff summary
109
+
110
+ ${_bold("Skills:")}
111
+ ${_cyan("wispy skill list")} List skills
112
+ ${_cyan("wispy skill show <name>")} Show skill details
113
+ ${_cyan("wispy teach <name>")} Create skill from conversation
114
+ ${_cyan("wispy improve <name> [notes]")} Improve a skill
115
+
116
+ ${_bold("Channels & Bots:")}
117
+ ${_cyan("wispy channel setup <name>")} Setup channel (telegram/discord/slack/…)
118
+ ${_cyan("wispy channel list")} List configured channels
119
+ ${_cyan("wispy channel test <name>")} Test channel connection
120
+ ${_cyan("wispy --serve")} Start all channel bots
121
+
122
+ ${_bold("Server & Deploy:")}
123
+ ${_cyan("wispy server")} Start API server
124
+ ${_cyan("wispy deploy")} Deploy help & config generators
125
+ ${_cyan("wispy node")} Node (multi-machine) management
126
+ ${_cyan("wispy sync")} Sync with remote server
127
+
128
+ ${_bold("Cron & Automation:")}
129
+ ${_cyan("wispy cron list")} List cron jobs
130
+ ${_cyan("wispy cron add")} Add a cron job
131
+ ${_cyan("wispy cron start")} Start scheduler
132
+
133
+ ${_bold("Config & Maintenance:")}
134
+ ${_cyan("wispy setup")} Configure wispy interactively
135
+ ${_cyan("wispy update")} Update to latest version
136
+ ${_cyan("wispy migrate")} Import from OpenClaw
137
+ ${_cyan("wispy doctor")} Check system health
138
+ ${_cyan("wispy version")} Show version
139
+
140
+ ${_bold("Shell Completions:")}
141
+ ${_cyan("wispy completion bash")} Bash completion script
142
+ ${_cyan("wispy completion zsh")} Zsh completion script
143
+
144
+ ${_bold("Flags:")}
145
+ ${_cyan("--help, -h")} Show this help
146
+ ${_cyan("--version, -v")} Show version
147
+ ${_cyan("--debug")} Verbose logs & stack traces
148
+ ${_cyan("WISPY_DEBUG=1")} Same as --debug via env
149
+
150
+ ${_dim("Run 'wispy help <command>' for detailed help on a specific command.")}
151
+ `);
152
+ process.exit(0);
153
+ }
154
+
155
+ // ── help sub-command (detailed per-command help) ──────────────────────────────
156
+ if (args[0] === "help") {
157
+ const topic = args[1];
158
+ const helpTexts = {
159
+ ws: `
160
+ ${_bold("wispy ws")} — Workstream management
161
+
162
+ ${_bold("Usage:")}
163
+ wispy ws List all workstreams
164
+ wispy ws new <name> Create a new workstream
165
+ wispy ws switch <name> Switch to a workstream
166
+ wispy ws archive <name> Archive a workstream (move sessions/memory)
167
+ wispy ws delete <name> Permanently delete a workstream
168
+ wispy ws status Status overview of all workstreams
169
+ wispy ws search <query> Search across all workstreams
170
+
171
+ ${_bold("Examples:")}
172
+ wispy ws new project-x
173
+ wispy ws switch project-x
174
+ wispy ws archive old-project
175
+ `,
176
+ trust: `
177
+ ${_bold("wispy trust")} — Trust levels and security
178
+
179
+ ${_bold("Usage:")}
180
+ wispy trust Show current trust level and policies
181
+ wispy trust careful Require approval for everything
182
+ wispy trust balanced Approve only risky operations
183
+ wispy trust yolo Auto-approve everything
184
+
185
+ ${_bold("Trust levels:")}
186
+ careful — Review every tool call before execution
187
+ balanced — Auto-approve safe ops, review destructive ones
188
+ yolo — Full automation (use with care!)
189
+ `,
190
+ deploy: `
191
+ ${_bold("wispy deploy")} — Deployment helpers
192
+
193
+ ${_bold("Usage:")}
194
+ wispy deploy init Generate Dockerfile + compose + .env.example
195
+ wispy deploy dockerfile Print Dockerfile
196
+ wispy deploy compose Print docker-compose.yml
197
+ wispy deploy systemd Print systemd unit
198
+ wispy deploy railway Print railway.json
199
+ wispy deploy fly Print fly.toml
200
+ wispy deploy render Print render.yaml
201
+ wispy deploy modal Generate Modal serverless config
202
+ wispy deploy daytona Generate Daytona workspace config
203
+ wispy deploy vps user@host SSH deploy to VPS
204
+ wispy deploy status <url> Check remote server health
205
+
206
+ ${_bold("Examples:")}
207
+ wispy deploy init
208
+ wispy deploy vps root@my.vps
209
+ wispy connect https://my.vps:18790 --token <token>
210
+ `,
211
+ cron: `
212
+ ${_bold("wispy cron")} — Scheduled task management
213
+
214
+ ${_bold("Usage:")}
215
+ wispy cron list List all cron jobs
216
+ wispy cron add Interactive job creation
217
+ wispy cron remove <id> Delete a cron job
218
+ wispy cron run <id> Run a job immediately
219
+ wispy cron history <id> Show past runs
220
+ wispy cron start Start scheduler in foreground
221
+
222
+ ${_bold("Schedule types:")}
223
+ cron — standard cron expression (e.g. "0 9 * * *")
224
+ every — interval in minutes
225
+ at — one-time ISO datetime
226
+ `,
227
+ channel: `
228
+ ${_bold("wispy channel")} — Messaging channel setup
229
+
230
+ ${_bold("Usage:")}
231
+ wispy channel setup telegram Telegram bot setup
232
+ wispy channel setup discord Discord bot setup
233
+ wispy channel setup slack Slack bot setup
234
+ wispy channel setup whatsapp WhatsApp setup
235
+ wispy channel setup email Email setup
236
+ wispy channel list List configured channels
237
+ wispy channel test <name> Test channel connection
238
+
239
+ ${_bold("Running bots:")}
240
+ wispy --serve Start all configured bots
241
+ wispy --telegram Start Telegram bot only
242
+ wispy --discord Start Discord bot only
243
+ `,
244
+ sync: `
245
+ ${_bold("wispy sync")} — Remote sync
246
+
247
+ ${_bold("Usage:")}
248
+ wispy sync Bidirectional sync
249
+ wispy sync push Push local → remote
250
+ wispy sync pull Pull remote → local
251
+ wispy sync status Show sync status
252
+ wispy sync auto Enable auto-sync
253
+ wispy sync auto --off Disable auto-sync
254
+
255
+ ${_bold("Flags:")}
256
+ --remote <url> Remote server URL
257
+ --token <token> Auth token
258
+ --strategy newer-wins Conflict resolution (newer-wins|local-wins|remote-wins)
259
+ --memory-only Only sync memory files
260
+ --sessions-only Only sync sessions
261
+ `,
262
+ skill: `
263
+ ${_bold("wispy skill")} — Skill management
264
+
265
+ ${_bold("Usage:")}
266
+ wispy skill list List all installed skills
267
+ wispy skill show <name> Show skill details
268
+ wispy teach <name> Create a skill from current conversation
269
+ wispy improve <name> Improve an existing skill
270
+
271
+ ${_bold("In REPL:")}
272
+ /skills List skills
273
+ /teach <name> Teach from conversation
274
+ /<skill-name> Invoke any skill
275
+ `,
276
+ doctor: `
277
+ ${_bold("wispy doctor")} — System health check
278
+
279
+ Checks Node.js version, config, API keys, directory permissions,
280
+ optional dependencies, and remote server connectivity.
281
+
282
+ ${_bold("Usage:")}
283
+ wispy doctor
284
+ `,
285
+ node: `
286
+ ${_bold("wispy node")} — Multi-machine node management
287
+
288
+ ${_bold("Usage:")}
289
+ wispy node pair Generate pairing code
290
+ wispy node connect <code> --url <url> Connect as a node
291
+ wispy node list Show registered nodes
292
+ wispy node status Ping all nodes
293
+ wispy node remove <id> Unregister a node
294
+ `,
295
+ };
296
+
297
+ if (topic && helpTexts[topic]) {
298
+ console.log(helpTexts[topic]);
299
+ } else if (topic) {
300
+ console.log(_yellow(`\n⚠️ No detailed help for '${topic}'. Try 'wispy --help' for the full command list.\n`));
301
+ } else {
302
+ // Generic help — same as --help
303
+ args[0] = "--help";
304
+ // Re-trigger help by falling through (not possible here, just print it)
305
+ console.log(`Run ${_cyan("wispy --help")} for the full command list.`);
306
+ console.log(`Run ${_cyan("wispy help <command>")} for per-command help.`);
307
+ console.log(`\nAvailable topics: ${Object.keys(helpTexts).map(k => _cyan(k)).join(", ")}`);
308
+ }
309
+ process.exit(0);
310
+ }
311
+
312
+ // ── completion sub-command ────────────────────────────────────────────────────
313
+ if (args[0] === "completion") {
314
+ const shell = args[1] ?? "bash";
315
+ const cmds = ["ws", "trust", "where", "handoff", "skill", "teach", "improve", "dry",
316
+ "deploy", "server", "node", "channel", "cron", "audit", "sync", "setup", "update",
317
+ "tui", "migrate", "version", "doctor", "help", "completion", "status",
318
+ "connect", "disconnect", "log"];
319
+ const flags = ["--help", "--version", "--debug", "--serve", "--telegram", "--discord", "--slack"];
320
+
321
+ if (shell === "bash") {
322
+ console.log(`# wispy bash completion
323
+ # Add to ~/.bashrc: eval "$(wispy completion bash)"
324
+ _wispy_completion() {
325
+ local cur prev words
326
+ COMPREPLY=()
327
+ cur="\${COMP_WORDS[COMP_CWORD]}"
328
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
329
+ local commands="${cmds.join(" ")}"
330
+ local flags="${flags.join(" ")}"
331
+
332
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
333
+ COMPREPLY=( $(compgen -W "\${commands} \${flags}" -- "\${cur}") )
334
+ fi
335
+ }
336
+ complete -F _wispy_completion wispy`);
337
+ } else if (shell === "zsh") {
338
+ console.log(`# wispy zsh completion
339
+ # Add to ~/.zshrc: eval "$(wispy completion zsh)"
340
+ _wispy() {
341
+ local -a commands
342
+ commands=(
343
+ ${cmds.map(c => `'${c}'`).join("\n ")}
344
+ )
345
+ local -a flags
346
+ flags=(${flags.map(f => `'${f}'`).join(" ")})
347
+ _arguments -C \\
348
+ '1:command:->cmds' \\
349
+ '*::arg:->args'
350
+ case $state in
351
+ cmds) _describe 'wispy commands' commands ;;
352
+ esac
353
+ }
354
+ compdef _wispy wispy`);
355
+ } else if (shell === "fish") {
356
+ const completions = cmds.map(c => `complete -c wispy -f -n '__fish_use_subcommand' -a '${c}'`).join("\n");
357
+ console.log(`# wispy fish completion\n# Save to ~/.config/fish/completions/wispy.fish\n${completions}`);
358
+ } else {
359
+ console.error(_red(`❌ Unknown shell: ${shell}. Use: bash, zsh, or fish`));
360
+ process.exit(2);
361
+ }
362
+ process.exit(0);
363
+ }
364
+
365
+ // ── doctor sub-command ────────────────────────────────────────────────────────
366
+ if (args[0] === "doctor") {
367
+ const { access, readFile: rf, stat } = await import("node:fs/promises");
368
+ const { homedir } = await import("node:os");
369
+ const wispyDir = path.join(homedir(), ".wispy");
370
+ const configPath = path.join(wispyDir, "config.json");
371
+ const memoryDir = path.join(wispyDir, "memory");
372
+ const sessionsDir = path.join(wispyDir, "sessions");
373
+
374
+ console.log(`\n${_bold("🩺 Wispy Doctor")} ${_dim(`v${getVersion()}`)}\n`);
375
+
376
+ let allOk = true;
377
+ const issues = [];
378
+
379
+ function check(label, ok, detail = "") {
380
+ if (ok) {
381
+ console.log(` ${_green("✅")} ${label}${detail ? _dim(" " + detail) : ""}`);
382
+ } else {
383
+ console.log(` ${_red("❌")} ${label}${detail ? _dim(" " + detail) : ""}`);
384
+ allOk = false;
385
+ issues.push(label);
386
+ }
387
+ }
388
+
389
+ function info(label, detail) {
390
+ console.log(` ${_cyan("ℹ️ ")} ${label}${detail ? _dim(" " + detail) : ""}`);
391
+ }
392
+
393
+ // 1. Node.js version
394
+ const [major] = process.version.replace("v", "").split(".").map(Number);
395
+ check("Node.js version", major >= 18, `${process.version} (required: >= 18)`);
396
+
397
+ // 2. Config file
398
+ let config = null;
399
+ try {
400
+ const raw = await rf(configPath, "utf8");
401
+ config = JSON.parse(raw);
402
+ check("Config file", true, configPath);
403
+ } catch (e) {
404
+ if (e.code === "ENOENT") {
405
+ check("Config file", false, `Not found at ${configPath} — run 'wispy setup'`);
406
+ } else {
407
+ check("Config file", false, `Malformed JSON — run 'wispy setup' to reconfigure`);
408
+ }
409
+ }
410
+
411
+ // 3. API key configured
412
+ if (config) {
413
+ const provider = config.provider ?? "unknown";
414
+ const envMap = {
415
+ google: "GOOGLE_AI_KEY",
416
+ anthropic: "ANTHROPIC_API_KEY",
417
+ openai: "OPENAI_API_KEY",
418
+ groq: "GROQ_API_KEY",
419
+ openrouter: "OPENROUTER_API_KEY",
420
+ deepseek: "DEEPSEEK_API_KEY",
421
+ ollama: null,
422
+ };
423
+ const envKey = envMap[provider];
424
+ if (envKey === null) {
425
+ check("AI provider (Ollama)", true, "no key needed");
426
+ } else if (envKey) {
427
+ const key = config.apiKey || process.env[envKey];
428
+ check(`API key (${provider})`, !!key && key.length > 10, key ? "configured" : `run 'wispy setup provider'`);
429
+ } else {
430
+ check("AI provider", false, `Unknown provider: ${provider}`);
431
+ }
432
+ } else {
433
+ info("AI provider", "skipped (no config)");
434
+ }
435
+
436
+ // 4. Memory directory writable
437
+ try {
438
+ const { mkdir: mkd, writeFile: wf, unlink: unl } = await import("node:fs/promises");
439
+ await mkd(memoryDir, { recursive: true });
440
+ const testFile = path.join(memoryDir, ".write-test");
441
+ await wf(testFile, "test", "utf8");
442
+ await unl(testFile);
443
+ check("Memory dir writable", true, memoryDir);
444
+ } catch (e) {
445
+ check("Memory dir writable", false, e.message);
446
+ }
447
+
448
+ // 5. Sessions directory writable
449
+ try {
450
+ const { mkdir: mkd, writeFile: wf, unlink: unl } = await import("node:fs/promises");
451
+ await mkd(sessionsDir, { recursive: true });
452
+ const testFile = path.join(sessionsDir, ".write-test");
453
+ await wf(testFile, "test", "utf8");
454
+ await unl(testFile);
455
+ check("Sessions dir writable", true, sessionsDir);
456
+ } catch (e) {
457
+ check("Sessions dir writable", false, e.message);
458
+ }
459
+
460
+ // 6. Optional deps
461
+ console.log(`\n ${_bold("Optional dependencies:")}`);
462
+ const optDeps = [
463
+ { pkg: "grammy", label: "Telegram (grammy)" },
464
+ { pkg: "discord.js", label: "Discord (discord.js)" },
465
+ { pkg: "@slack/bolt", label: "Slack (@slack/bolt)" },
466
+ { pkg: "whatsapp-web.js", label: "WhatsApp (whatsapp-web.js)" },
467
+ { pkg: "nodemailer", label: "Email (nodemailer)" },
468
+ { pkg: "imapflow", label: "Email IMAP (imapflow)" },
469
+ ];
470
+ for (const dep of optDeps) {
471
+ try {
472
+ await import(dep.pkg);
473
+ check(dep.label, true, "installed");
474
+ } catch {
475
+ console.log(` ${_dim("–")} ${_dim(dep.label + " not installed (optional)")}`);
476
+ }
477
+ }
478
+
479
+ // 7. Remote server (if configured)
480
+ const remotePath = path.join(wispyDir, "remote.json");
481
+ try {
482
+ const remote = JSON.parse(await rf(remotePath, "utf8"));
483
+ if (remote?.url) {
484
+ console.log(`\n ${_bold("Remote server:")}`);
485
+ process.stdout.write(` ${_cyan("🔄")} Checking ${remote.url}... `);
486
+ try {
487
+ const resp = await fetch(`${remote.url}/api/health`, { signal: AbortSignal.timeout(5000) });
488
+ if (resp.ok) {
489
+ console.log(_green("✅ reachable"));
490
+ } else {
491
+ console.log(_yellow(`⚠️ HTTP ${resp.status}`));
492
+ allOk = false;
493
+ }
494
+ } catch {
495
+ console.log(_red("❌ unreachable"));
496
+ allOk = false;
497
+ issues.push("Remote server unreachable");
498
+ }
499
+ }
500
+ } catch {}
501
+
502
+ console.log("");
503
+ if (allOk) {
504
+ console.log(`${_green("✅ All checks passed!")}\n`);
505
+ } else {
506
+ console.log(`${_yellow("⚠️ Issues found:")} ${issues.join(", ")}`);
507
+ console.log(_dim(" Run 'wispy setup' to fix configuration issues.\n"));
508
+ }
509
+ process.exit(allOk ? 0 : 1);
510
+ }
511
+
512
+ // ── config validation helper ───────────────────────────────────────────────────
513
+ async function validateConfigOnStartup() {
514
+ const { homedir } = await import("node:os");
515
+ const { readFile: rf } = await import("node:fs/promises");
516
+ const configPath = path.join(homedir(), ".wispy", "config.json");
517
+ try {
518
+ const raw = await rf(configPath, "utf8");
519
+ JSON.parse(raw);
520
+ } catch (e) {
521
+ if (e instanceof SyntaxError) {
522
+ console.error(_red("❌ Config file corrupted. Run 'wispy setup' to reconfigure."));
523
+ process.exit(1);
524
+ }
525
+ // File not found is OK (first run)
526
+ }
527
+ }
528
+
26
529
  // ── ws sub-command ────────────────────────────────────────────────────────────
27
530
  if (args[0] === "ws") {
28
- const { handleWsCommand } = await import(
29
- path.join(__dirname, "..", "lib", "commands", "ws.mjs")
30
- );
31
- await handleWsCommand(args);
531
+ try {
532
+ const { handleWsCommand } = await import(
533
+ path.join(__dirname, "..", "lib", "commands", "ws.mjs")
534
+ );
535
+ await handleWsCommand(args);
536
+ } catch (e) { friendlyError(e); }
32
537
  process.exit(0);
33
538
  }
34
539
 
35
540
  // ── trust sub-command ─────────────────────────────────────────────────────────
36
541
  if (args[0] === "trust") {
37
- const { handleTrustCommand } = await import(
38
- path.join(__dirname, "..", "lib", "commands", "trust.mjs")
39
- );
40
- await handleTrustCommand(args);
542
+ try {
543
+ const { handleTrustCommand } = await import(
544
+ path.join(__dirname, "..", "lib", "commands", "trust.mjs")
545
+ );
546
+ await handleTrustCommand(args);
547
+ } catch (e) { friendlyError(e); }
41
548
  process.exit(0);
42
549
  }
43
550
 
44
551
  // ── where sub-command ─────────────────────────────────────────────────────────
45
552
  if (args[0] === "where") {
46
- const { cmdWhere } = await import(
47
- path.join(__dirname, "..", "lib", "commands", "continuity.mjs")
48
- );
49
- await cmdWhere();
553
+ try {
554
+ const { cmdWhere } = await import(
555
+ path.join(__dirname, "..", "lib", "commands", "continuity.mjs")
556
+ );
557
+ await cmdWhere();
558
+ } catch (e) { friendlyError(e); }
50
559
  process.exit(0);
51
560
  }
52
561
 
53
562
  // ── handoff sub-command ───────────────────────────────────────────────────────
54
563
  if (args[0] === "handoff") {
55
- const { handleContinuityCommand } = await import(
56
- path.join(__dirname, "..", "lib", "commands", "continuity.mjs")
57
- );
58
- await handleContinuityCommand(args);
564
+ try {
565
+ const { handleContinuityCommand } = await import(
566
+ path.join(__dirname, "..", "lib", "commands", "continuity.mjs")
567
+ );
568
+ await handleContinuityCommand(args);
569
+ } catch (e) { friendlyError(e); }
59
570
  process.exit(0);
60
571
  }
61
572
 
62
573
  // ── skill sub-command ─────────────────────────────────────────────────────────
63
574
  if (args[0] === "skill") {
64
- const { handleSkillCommand } = await import(
65
- path.join(__dirname, "..", "lib", "commands", "skills-cmd.mjs")
66
- );
67
- await handleSkillCommand(args);
575
+ try {
576
+ const { handleSkillCommand } = await import(
577
+ path.join(__dirname, "..", "lib", "commands", "skills-cmd.mjs")
578
+ );
579
+ await handleSkillCommand(args);
580
+ } catch (e) { friendlyError(e); }
68
581
  process.exit(0);
69
582
  }
70
583
 
71
584
  // ── teach sub-command ─────────────────────────────────────────────────────────
72
585
  if (args[0] === "teach") {
73
- const { cmdTeach } = await import(
74
- path.join(__dirname, "..", "lib", "commands", "skills-cmd.mjs")
75
- );
76
- await cmdTeach(args[1]);
586
+ try {
587
+ const { cmdTeach } = await import(
588
+ path.join(__dirname, "..", "lib", "commands", "skills-cmd.mjs")
589
+ );
590
+ await cmdTeach(args[1]);
591
+ } catch (e) { friendlyError(e); }
77
592
  process.exit(0);
78
593
  }
79
594
 
80
595
  // ── improve sub-command ───────────────────────────────────────────────────────
81
596
  if (args[0] === "improve") {
82
- const { cmdImproveSkill } = await import(
83
- path.join(__dirname, "..", "lib", "commands", "skills-cmd.mjs")
84
- );
85
- const name = args[1];
86
- const feedback = args.slice(2).join(" ").replace(/^["']|["']$/g, "");
87
- await cmdImproveSkill(name, feedback);
597
+ try {
598
+ const { cmdImproveSkill } = await import(
599
+ path.join(__dirname, "..", "lib", "commands", "skills-cmd.mjs")
600
+ );
601
+ const name = args[1];
602
+ const feedback = args.slice(2).join(" ").replace(/^["']|["']$/g, "");
603
+ await cmdImproveSkill(name, feedback);
604
+ } catch (e) { friendlyError(e); }
88
605
  process.exit(0);
89
606
  }
90
607
 
@@ -103,38 +620,40 @@ if (args[0] === "dry") {
103
620
 
104
621
  // ── setup / init sub-command ──────────────────────────────────────────────────
105
622
  if (args[0] === "setup" || args[0] === "init") {
106
- const { OnboardingWizard } = await import(
107
- path.join(__dirname, "..", "core", "onboarding.mjs")
108
- );
109
- const wizard = new OnboardingWizard();
110
- const sub = args[1]; // e.g. "provider", "channels", "security"
111
- if (sub && sub !== "wizard") {
112
- await wizard.runStep(sub);
113
- } else {
114
- await wizard.run();
115
- }
623
+ // Handle Ctrl+C gracefully in setup
624
+ process.removeAllListeners("SIGINT");
625
+ process.on("SIGINT", () => { console.log(_dim("\nSetup cancelled.")); process.exit(130); });
626
+ try {
627
+ const { OnboardingWizard } = await import(
628
+ path.join(__dirname, "..", "core", "onboarding.mjs")
629
+ );
630
+ const wizard = new OnboardingWizard();
631
+ const sub = args[1]; // e.g. "provider", "channels", "security"
632
+ if (sub && sub !== "wizard") {
633
+ await wizard.runStep(sub);
634
+ } else {
635
+ await wizard.run();
636
+ }
637
+ } catch (e) { friendlyError(e); }
116
638
  process.exit(0);
117
639
  }
118
640
 
119
641
  // ── update sub-command ────────────────────────────────────────────────────────
120
642
  if (args[0] === "update") {
121
- const { execSync } = await import("node:child_process");
122
- console.log("🌿 Checking for updates...");
123
643
  try {
124
- const current = JSON.parse(await import("node:fs").then(f => f.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"))).version;
644
+ const { execSync } = await import("node:child_process");
645
+ console.log(_cyan("🔄 Checking for updates..."));
646
+ const current = getVersion();
125
647
  const latest = execSync("npm info wispy-cli version", { encoding: "utf8" }).trim();
126
648
  if (current === latest) {
127
- console.log(`✅ Already on latest version (${current})`);
649
+ console.log(_green(`✅ Already on latest version (${current})`));
128
650
  } else {
129
- console.log(`📦 ${current} → ${latest}`);
130
- console.log("Updating...");
651
+ console.log(`📦 ${_dim(current)} → ${_bold(latest)}`);
652
+ console.log(_cyan("🔄 Updating..."));
131
653
  execSync("npm install -g wispy-cli@latest", { stdio: "inherit" });
132
- console.log(`\n✅ Updated to ${latest}`);
654
+ console.log(_green(`\n✅ Updated to ${latest}`));
133
655
  }
134
- } catch (e) {
135
- console.error(`❌ Update failed: ${e.message}`);
136
- process.exit(1);
137
- }
656
+ } catch (e) { friendlyError(e); }
138
657
  process.exit(0);
139
658
  }
140
659
 
@@ -147,7 +666,7 @@ if (args[0] === "status") {
147
666
  );
148
667
  await printStatus();
149
668
  process.exit(0);
150
- } catch {}
669
+ } catch (e) { if (DEBUG) console.error(e); }
151
670
 
152
671
  // Fallback: original status (remote check)
153
672
  const { readFile } = await import("node:fs/promises");
@@ -1062,6 +1581,45 @@ if (args[0] === "channel") {
1062
1581
  process.exit(0);
1063
1582
  }
1064
1583
 
1584
+ // ── Unknown command detection ─────────────────────────────────────────────────
1585
+ // Any non-flag argument that wasn't matched above is an unknown command
1586
+ const _KNOWN_COMMANDS = new Set([
1587
+ "ws", "trust", "where", "handoff", "skill", "teach", "improve", "dry",
1588
+ "setup", "init", "update", "status", "connect", "disconnect", "sync",
1589
+ "deploy", "migrate", "cron", "audit", "log", "server", "node", "channel",
1590
+ "tui", "help", "doctor", "completion", "version",
1591
+ // serve flags (handled below)
1592
+ "--serve", "--telegram", "--discord", "--slack", "--server",
1593
+ "--help", "-h", "--version", "-v", "--debug", "--tui",
1594
+ // workstream flags
1595
+ "-w", "--workstream",
1596
+ ]);
1597
+ const _firstArg = args[0];
1598
+ if (_firstArg && _firstArg[0] !== "-" && !_KNOWN_COMMANDS.has(_firstArg)) {
1599
+ // Not a known command — but could be a one-shot message (no quotes needed)
1600
+ // Heuristic: if it looks like a real command word (no spaces, short), warn.
1601
+ // If it looks like a natural language sentence, fall through to REPL one-shot mode.
1602
+ const looksLikeCommand = /^[a-z][a-z0-9_-]{0,20}$/.test(_firstArg);
1603
+ if (looksLikeCommand) {
1604
+ // Show unknown command error
1605
+ const suggestions = [
1606
+ { cmd: "setup", desc: "configure wispy" },
1607
+ { cmd: "tui", desc: "workspace UI" },
1608
+ { cmd: "ws", desc: "workstream management" },
1609
+ { cmd: "doctor", desc: "check system health" },
1610
+ { cmd: "help", desc: "show detailed help" },
1611
+ ];
1612
+ console.error(`\n${_red(`❌ Unknown command: ${_firstArg}`)}`);
1613
+ console.error(`\n${_bold("Did you mean one of these?")}`);
1614
+ for (const { cmd, desc } of suggestions) {
1615
+ console.error(` ${_cyan("wispy " + cmd.padEnd(12))}— ${desc}`);
1616
+ }
1617
+ console.error(`\n${_dim("Run wispy --help for all commands.")}\n`);
1618
+ process.exit(2);
1619
+ }
1620
+ // Otherwise fall through to one-shot message mode in REPL
1621
+ }
1622
+
1065
1623
  // ── Bot / serve modes ─────────────────────────────────────────────────────────
1066
1624
  const serveMode = args.includes("--serve");
1067
1625
  const telegramMode = args.includes("--telegram");
@@ -1146,17 +1704,28 @@ if (isInteractiveStart) {
1146
1704
  }
1147
1705
  }
1148
1706
 
1707
+ // Validate config before starting interactive modes
1708
+ await validateConfigOnStartup();
1709
+
1149
1710
  // ── TUI mode ──────────────────────────────────────────────────────────────────
1150
1711
  // `wispy ui` is the primary way; `--tui` is kept as a hidden backwards-compat alias
1151
1712
  const tuiMode = args[0] === "tui" || args.includes("--tui");
1152
1713
 
1153
1714
  if (tuiMode) {
1715
+ // Override SIGINT for clean TUI exit
1716
+ process.removeAllListeners("SIGINT");
1717
+ process.on("SIGINT", () => { process.exit(130); });
1718
+
1154
1719
  const newArgs = args.filter(a => a !== "--tui" && a !== "tui");
1155
1720
  process.argv = [process.argv[0], process.argv[1], ...newArgs];
1156
1721
  const tuiScript = path.join(__dirname, "..", "lib", "wispy-tui.mjs");
1157
- await import(tuiScript);
1722
+ try {
1723
+ await import(tuiScript);
1724
+ } catch (e) { friendlyError(e); }
1158
1725
  } else {
1159
1726
  // Default: interactive REPL
1160
1727
  const mainScript = path.join(__dirname, "..", "lib", "wispy-repl.mjs");
1161
- await import(mainScript);
1728
+ try {
1729
+ await import(mainScript);
1730
+ } catch (e) { friendlyError(e); }
1162
1731
  }
package/core/migrate.mjs CHANGED
@@ -71,6 +71,7 @@ export class OpenClawMigrator {
71
71
  await this._migrateWorkspaceFiles(dryRun);
72
72
  await this._migrateCronJobs(dryRun);
73
73
  await this._migrateChannels(dryRun);
74
+ await this._migrateApiKeys(dryRun);
74
75
  }
75
76
 
76
77
  return {
@@ -274,9 +275,11 @@ export class OpenClawMigrator {
274
275
  } catch { /* empty */ }
275
276
 
276
277
  // Extract Telegram config
277
- const telegramToken = config.telegram?.token
278
+ const telegramToken = config.channels?.telegram?.botToken
279
+ ?? config.telegram?.token
278
280
  ?? config.channels?.telegram?.token
279
- ?? config.plugins?.telegram?.token;
281
+ ?? config.plugins?.telegram?.token
282
+ ?? config.plugins?.entries?.telegram?.config?.botToken;
280
283
 
281
284
  if (telegramToken && !wispyChannels.telegram) {
282
285
  wispyChannels.telegram = { token: telegramToken };
@@ -302,6 +305,83 @@ export class OpenClawMigrator {
302
305
  }
303
306
  }
304
307
 
308
+ // ── API keys migration ───────────────────────────────────────────────────────
309
+
310
+ async _migrateApiKeys(dryRun) {
311
+ // Read API keys from environment (same as OpenClaw uses)
312
+ const keyMap = {
313
+ google: { env: ["GOOGLE_AI_KEY", "GOOGLE_GENERATIVE_AI_KEY", "GEMINI_API_KEY"], model: "gemini-2.5-flash" },
314
+ anthropic: { env: ["ANTHROPIC_API_KEY"], model: "claude-sonnet-4-20250514" },
315
+ openai: { env: ["OPENAI_API_KEY"], model: "gpt-4o" },
316
+ groq: { env: ["GROQ_API_KEY"], model: "llama-3.3-70b-versatile" },
317
+ deepseek: { env: ["DEEPSEEK_API_KEY"], model: "deepseek-chat" },
318
+ openrouter:{ env: ["OPENROUTER_API_KEY"], model: "anthropic/claude-sonnet-4-20250514" },
319
+ };
320
+
321
+ // Also check macOS keychain
322
+ const { execSync } = await import("node:child_process");
323
+ function keychainGet(service, account) {
324
+ try {
325
+ return execSync(`security find-generic-password -s "${service}" -a "${account}" -w 2>/dev/null`, { encoding: "utf8" }).trim();
326
+ } catch { return null; }
327
+ }
328
+
329
+ const keychainMap = {
330
+ google: { service: "google-ai-key", account: "poropo" },
331
+ anthropic: { service: "anthropic-api-key", account: "poropo" },
332
+ openai: { service: "openai-api-key", account: "poropo" },
333
+ };
334
+
335
+ const configPath = join(this.wispyDir, "config.json");
336
+ let config = {};
337
+ try { config = JSON.parse(await readFile(configPath, "utf8")); } catch { /* empty */ }
338
+ if (!config.providers) config.providers = {};
339
+
340
+ let found = 0;
341
+ for (const [provider, info] of Object.entries(keyMap)) {
342
+ if (config.providers[provider]?.apiKey) continue; // already configured
343
+
344
+ // Check env vars
345
+ let key = null;
346
+ for (const envName of info.env) {
347
+ if (process.env[envName]) { key = process.env[envName]; break; }
348
+ }
349
+
350
+ // Check keychain (macOS)
351
+ if (!key && keychainMap[provider]) {
352
+ key = keychainGet(keychainMap[provider].service, keychainMap[provider].account);
353
+ }
354
+
355
+ // Check ~/.zshenv for exports
356
+ if (!key) {
357
+ try {
358
+ const zshenv = await readFile(join(homedir(), ".zshenv"), "utf8");
359
+ for (const envName of info.env) {
360
+ const match = zshenv.match(new RegExp(`export\\s+${envName}=["']?([^"'\\n]+)`));
361
+ if (match) { key = match[1]; break; }
362
+ }
363
+ } catch { /* no zshenv */ }
364
+ }
365
+
366
+ if (key) {
367
+ found++;
368
+ this.report.apiKeys = this.report.apiKeys || [];
369
+ this.report.apiKeys.push({ provider, masked: key.slice(0, 6) + "..." + key.slice(-4) });
370
+
371
+ if (!dryRun) {
372
+ config.providers[provider] = { apiKey: key, model: info.model };
373
+ if (!config.defaultProvider) config.defaultProvider = provider;
374
+ }
375
+ }
376
+ }
377
+
378
+ if (found > 0 && !dryRun) {
379
+ config.onboarded = true;
380
+ await mkdir(this.wispyDir, { recursive: true });
381
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
382
+ }
383
+ }
384
+
305
385
  // ── Report formatting ─────────────────────────────────────────────────────────
306
386
 
307
387
  formatReport() {
@@ -345,6 +425,13 @@ export class OpenClawMigrator {
345
425
  }
346
426
  }
347
427
 
428
+ if (r.apiKeys?.length > 0) {
429
+ lines.push(`🔑 API Keys (${r.apiKeys.length}):`);
430
+ for (const k of r.apiKeys) {
431
+ lines.push(` import: ${k.provider} (${k.masked})`);
432
+ }
433
+ }
434
+
348
435
  if (r.errors.length > 0) {
349
436
  lines.push(`⚠️ Errors (${r.errors.length}):`);
350
437
  for (const e of r.errors) {
@@ -221,6 +221,16 @@ export async function cmdWsArchive(name) {
221
221
  return;
222
222
  }
223
223
 
224
+ // Confirmation prompt
225
+ const { createInterface } = await import("node:readline");
226
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
227
+ const answer = await new Promise(r => rl.question(`Archive '${name}'? Sessions and memory will be moved. [Y/n] `, r));
228
+ rl.close();
229
+ if (answer.trim().toLowerCase() === "n") {
230
+ console.log(dim("Cancelled."));
231
+ return;
232
+ }
233
+
224
234
  const archiveWsDir = path.join(ARCHIVE_DIR, name);
225
235
  await mkdir(archiveWsDir, { recursive: true });
226
236
 
@@ -356,6 +366,16 @@ export async function cmdWsDelete(name) {
356
366
  return;
357
367
  }
358
368
 
369
+ // Confirmation prompt
370
+ const { createInterface } = await import("node:readline");
371
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
372
+ const answer = await new Promise(r => rl.question(`⚠️ Are you sure? This will delete all sessions and memory for '${name}'. [y/N] `, r));
373
+ rl.close();
374
+ if (answer.trim().toLowerCase() !== "y") {
375
+ console.log(dim("Cancelled."));
376
+ return;
377
+ }
378
+
359
379
  const wsDir = path.join(WORKSTREAMS_DIR, name);
360
380
  const convFile = path.join(CONVERSATIONS_DIR, `${name}.json`);
361
381
 
@@ -1151,10 +1151,13 @@ async function runRepl(engine) {
1151
1151
  } catch (err) {
1152
1152
  if (err.message.includes("429") || err.message.includes("rate")) {
1153
1153
  console.log(yellow("\n\n⏳ Rate limited — wait a moment and try again."));
1154
- } else if (err.message.includes("401") || err.message.includes("403")) {
1155
- console.log(red("\n\n🔑 Authentication error check your API key."));
1154
+ } else if (err.message.includes("401") || err.message.includes("403") || err.message.includes("invalid") || err.message.includes("API key")) {
1155
+ console.log(red("\n\n API key is invalid. Run 'wispy setup provider' to reconfigure."));
1156
+ } else if (err.message.includes("ENOTFOUND") || err.message.includes("ECONNREFUSED") || err.message.includes("fetch")) {
1157
+ console.log(red("\n\n❌ Cannot reach API. Check your connection."));
1156
1158
  } else {
1157
1159
  console.log(red(`\n\n❌ Error: ${err.message.slice(0, 200)}`));
1160
+ if (process.env.WISPY_DEBUG === "1") console.error(dim(err.stack));
1158
1161
  }
1159
1162
  }
1160
1163
 
@@ -1163,7 +1166,7 @@ async function runRepl(engine) {
1163
1166
 
1164
1167
  rl.on("close", () => {
1165
1168
  console.log(dim(`\n🌿 Bye! (${engine.providers.formatCost()})`));
1166
- engine.destroy();
1169
+ try { engine.destroy(); } catch {}
1167
1170
  process.exit(0);
1168
1171
  });
1169
1172
  }
@@ -1180,15 +1183,21 @@ async function runOneShot(engine, message) {
1180
1183
  console.log("");
1181
1184
  console.log(dim(engine.providers.formatCost()));
1182
1185
  } catch (err) {
1183
- if (err.message.includes("429")) {
1184
- console.error(yellow("\n⏳ Rate limited — try again shortly."));
1186
+ if (err.message.includes("429") || err.message.includes("rate")) {
1187
+ console.error(yellow("\n⏳ Rate limited — wait a moment and try again."));
1188
+ } else if (err.message.includes("401") || err.message.includes("403") || err.message.includes("invalid") || err.message.includes("API key")) {
1189
+ console.error(red("\n❌ API key is invalid. Run 'wispy setup provider' to reconfigure."));
1190
+ } else if (err.message.includes("ENOTFOUND") || err.message.includes("ECONNREFUSED") || err.message.includes("fetch")) {
1191
+ console.error(red("\n❌ Cannot reach API. Check your connection."));
1185
1192
  } else {
1186
- console.error(red(`\n❌ ${err.message.slice(0, 200)}`));
1193
+ console.error(red(`\n❌ Error: ${err.message.slice(0, 200)}`));
1194
+ if (process.env.WISPY_DEBUG === "1") console.error(dim(err.stack));
1195
+ else console.error(dim(" Run with WISPY_DEBUG=1 for details."));
1187
1196
  }
1188
- engine.destroy();
1197
+ try { engine.destroy(); } catch {}
1189
1198
  process.exit(1);
1190
1199
  }
1191
- engine.destroy();
1200
+ try { engine.destroy(); } catch {}
1192
1201
  process.exit(0);
1193
1202
  }
1194
1203
 
@@ -1243,6 +1252,8 @@ const engine = new WispyEngine({ workstream: ACTIVE_WORKSTREAM });
1243
1252
  const initResult = await engine.init();
1244
1253
 
1245
1254
  if (!initResult) {
1255
+ // Show friendly message before onboarding
1256
+ console.log(yellow("\n⚠️ No API key configured. Run 'wispy setup' to get started.\n"));
1246
1257
  // Delegate to unified onboarding wizard
1247
1258
  try {
1248
1259
  const { OnboardingWizard } = await import("../core/onboarding.mjs");
@@ -1262,8 +1273,15 @@ if (!initResult) {
1262
1273
 
1263
1274
  // Graceful cleanup
1264
1275
  process.on("exit", () => { try { engine.destroy(); } catch {} });
1265
- process.on("SIGINT", () => { engine.destroy(); process.exit(0); });
1266
- process.on("SIGTERM", () => { engine.destroy(); process.exit(0); });
1276
+ process.on("SIGINT", () => {
1277
+ console.log(dim("\n\n🌿 Bye!"));
1278
+ try { engine.destroy(); } catch {}
1279
+ process.exit(130);
1280
+ });
1281
+ process.on("SIGTERM", () => {
1282
+ try { engine.destroy(); } catch {}
1283
+ process.exit(0);
1284
+ });
1267
1285
 
1268
1286
  // Auto-start background server
1269
1287
  const serverStatus = await startServerIfNeeded();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",