wispy-cli 2.6.2 → 2.7.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.
package/bin/wispy.mjs CHANGED
@@ -820,15 +820,60 @@ if (args[0] === "model") {
820
820
  const config = await loadConfig();
821
821
  const configuredProviders = config.providers ? Object.keys(config.providers) : (config.provider ? [config.provider] : []);
822
822
 
823
- // wispy model (no args) — show current
823
+ // wispy model (no args) — interactive menu
824
824
  if (!sub) {
825
825
  const defaultP = config.defaultProvider ?? configuredProviders[0];
826
826
  const currentModel = config.providers?.[defaultP]?.model ?? config.model ?? PROVIDERS[defaultP]?.defaultModel ?? "unknown";
827
827
  console.log(`\n🤖 ${_bold("Current model")}: ${_cyan(defaultP)}:${_bold(currentModel)}\n`);
828
- if (configuredProviders.length > 1) {
829
- console.log(` Configured providers: ${configuredProviders.join(", ")}`);
830
- console.log(` ${_dim("Use 'wispy model list' to see all options")}`);
831
- console.log(` ${_dim("Use 'wispy model set <provider:model>' to switch")}\n`);
828
+
829
+ try {
830
+ const { select } = await import("@inquirer/prompts");
831
+
832
+ // Build choices from configured providers only
833
+ const modelChoices = [];
834
+ for (const p of configuredProviders) {
835
+ const models = KNOWN_MODELS[p] ?? [];
836
+ for (const m of models) {
837
+ const cur = config.providers?.[p]?.model ?? PROVIDERS[p]?.defaultModel;
838
+ modelChoices.push({
839
+ name: `${m} (${p})${cur === m ? _dim(" ✓ current") : ""}`,
840
+ value: `${p}:${m}`,
841
+ short: `${p}:${m}`,
842
+ });
843
+ }
844
+ }
845
+
846
+ if (modelChoices.length === 0) {
847
+ console.log(_dim(" No models available. Run 'wispy setup' to configure a provider.\n"));
848
+ process.exit(0);
849
+ }
850
+
851
+ let modelChoice;
852
+ try {
853
+ modelChoice = await select({ message: "Switch model:", choices: modelChoices });
854
+ } catch (e) {
855
+ if (e.name === "ExitPromptError") { process.exit(130); }
856
+ throw e;
857
+ }
858
+
859
+ const colonIdx = modelChoice.indexOf(":");
860
+ const provName = modelChoice.slice(0, colonIdx);
861
+ const modelName = modelChoice.slice(colonIdx + 1);
862
+
863
+ if (!config.providers) config.providers = {};
864
+ if (!config.providers[provName]) config.providers[provName] = {};
865
+ config.providers[provName].model = modelName;
866
+ if (!config.defaultProvider) config.defaultProvider = provName;
867
+ await saveConfig(config);
868
+ console.log(_green(`\n✅ Switched to ${_cyan(provName)}:${_bold(modelName)}\n`));
869
+ } catch (e) {
870
+ if (e.name === "ExitPromptError") { process.exit(130); }
871
+ // Fallback — just show current
872
+ if (configuredProviders.length > 1) {
873
+ console.log(` Configured providers: ${configuredProviders.join(", ")}`);
874
+ console.log(` ${_dim("Use 'wispy model list' to see all options")}`);
875
+ console.log(` ${_dim("Use 'wispy model set <provider:model>' to switch")}\n`);
876
+ }
832
877
  }
833
878
  process.exit(0);
834
879
  }
@@ -890,18 +935,66 @@ if (args[0] === "config") {
890
935
  const config = await loadConfig();
891
936
 
892
937
  if (!sub || sub === "show") {
893
- // wispy config — show current config (mask secrets)
894
- const display = JSON.parse(JSON.stringify(config));
895
- // Mask API keys
896
- if (display.providers) {
897
- for (const [k, v] of Object.entries(display.providers)) {
898
- if (v.apiKey) v.apiKey = v.apiKey.slice(0, 6) + "..." + v.apiKey.slice(-4);
938
+ // wispy config — interactive config menu
939
+ const { select: cfgSelect } = await import("@inquirer/prompts");
940
+
941
+ // Show current status first
942
+ const providers = config.providers ? Object.keys(config.providers) : (config.provider ? [config.provider] : []);
943
+ const providerStr = providers.length > 0 ? providers.join(", ") : _dim("not set");
944
+ const defaultProv = config.defaultProvider ?? config.provider ?? _dim("not set");
945
+ const security = config.security ?? config.securityLevel ?? _dim("not set");
946
+ const language = config.language ?? _dim("auto");
947
+ const wsName = config.workstream ?? "default";
948
+
949
+ console.log(`\n${_bold("Wispy Config")}\n`);
950
+ console.log(` Providers: ${providerStr}`);
951
+ console.log(` Default: ${defaultProv}`);
952
+ console.log(` Security: ${security}`);
953
+ console.log(` Language: ${language}`);
954
+ console.log(` Workstream: ${wsName}`);
955
+ console.log(` Config file: ${_dim(CONFIG_PATH)}`);
956
+ console.log("");
957
+
958
+ const action = await cfgSelect({
959
+ message: "What do you want to change?",
960
+ choices: [
961
+ { name: "Providers — add/remove AI providers", value: "provider" },
962
+ { name: "Security — change trust level", value: "security" },
963
+ { name: "Language — set preferred language", value: "language" },
964
+ { name: "Channels — configure messaging bots", value: "channels" },
965
+ { name: "Server — cloud/server settings", value: "server" },
966
+ { name: "View raw config (JSON)", value: "raw" },
967
+ { name: "Reset everything", value: "reset" },
968
+ { name: "Done", value: "done" },
969
+ ],
970
+ });
971
+
972
+ if (action === "done") {
973
+ process.exit(0);
974
+ } else if (action === "raw") {
975
+ const display = JSON.parse(JSON.stringify(config));
976
+ if (display.providers) {
977
+ for (const [k, v] of Object.entries(display.providers)) {
978
+ if (v.apiKey) v.apiKey = v.apiKey.slice(0, 6) + "..." + v.apiKey.slice(-4);
979
+ }
899
980
  }
981
+ console.log(JSON.stringify(display, null, 2));
982
+ } else if (action === "reset") {
983
+ const { confirm: cfgConfirm } = await import("@inquirer/prompts");
984
+ const yes = await cfgConfirm({ message: "Reset all configuration?", default: false });
985
+ if (yes) {
986
+ const { writeFile: wf } = await import("node:fs/promises");
987
+ await wf(CONFIG_PATH, "{}\n");
988
+ console.log(`${_green("✓")} Config reset. Run 'wispy setup' to reconfigure.`);
989
+ }
990
+ } else {
991
+ // Delegate to setup wizard step
992
+ const { OnboardingWizard } = await import(
993
+ path.join(__dirname, "..", "core", "onboarding.mjs")
994
+ );
995
+ const wizard = new OnboardingWizard();
996
+ await wizard.runStep(action);
900
997
  }
901
- if (display.apiKey) display.apiKey = display.apiKey.slice(0, 6) + "..." + display.apiKey.slice(-4);
902
- console.log(`\n${_bold("Wispy Config")} ${_dim(CONFIG_PATH)}\n`);
903
- console.log(JSON.stringify(display, null, 2));
904
- console.log("");
905
998
 
906
999
  } else if (sub === "get") {
907
1000
  // wispy config get <key>
@@ -1128,6 +1221,54 @@ if (args[0] === "sync") {
1128
1221
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
1129
1222
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1130
1223
 
1224
+ // Interactive sync menu when no subcommand
1225
+ if (!sub) {
1226
+ try {
1227
+ const { select, Separator } = await import("@inquirer/prompts");
1228
+ const cfg = await SyncManager.loadConfig();
1229
+ const remoteUrl = cfg.remoteUrl;
1230
+
1231
+ if (!remoteUrl) {
1232
+ console.log(_dim("\nNo remote configured. Connect first: wispy connect <url>\n"));
1233
+ process.exit(0);
1234
+ }
1235
+
1236
+ console.log(`\nRemote: ${_cyan(remoteUrl)} ✓\n`);
1237
+
1238
+ let syncAction;
1239
+ try {
1240
+ syncAction = await select({
1241
+ message: "Sync actions:",
1242
+ choices: [
1243
+ { name: "Sync now (bidirectional)", value: "sync" },
1244
+ { name: "Push local → remote", value: "push" },
1245
+ { name: "Pull remote → local", value: "pull" },
1246
+ { name: "View what would sync (status)", value: "status" },
1247
+ { name: "Enable auto-sync", value: "auto" },
1248
+ { name: "Disconnect remote", value: "disconnect" },
1249
+ ],
1250
+ });
1251
+ } catch (e) {
1252
+ if (e.name === "ExitPromptError") { process.exit(130); }
1253
+ throw e;
1254
+ }
1255
+
1256
+ if (["sync", "push", "pull", "status", "auto"].includes(syncAction)) {
1257
+ args[1] = syncAction;
1258
+ } else if (syncAction === "disconnect") {
1259
+ const { confirm } = await import("@inquirer/prompts");
1260
+ let ok;
1261
+ try { ok = await confirm({ message: "Disconnect remote?", default: false }); }
1262
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
1263
+ if (ok) { await SyncManager.disableAuto?.(); console.log(_dim("Remote disconnected.")); }
1264
+ process.exit(0);
1265
+ }
1266
+ } catch (e) {
1267
+ if (e.name === "ExitPromptError") { process.exit(130); }
1268
+ // Fall through
1269
+ }
1270
+ }
1271
+
1131
1272
  // Parse flags
1132
1273
  const strategyIdx = args.indexOf("--strategy");
1133
1274
  const strategy = strategyIdx !== -1 ? args[strategyIdx + 1] : null;
@@ -1256,6 +1397,93 @@ if (args[0] === "deploy") {
1256
1397
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
1257
1398
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
1258
1399
 
1400
+ if (!sub) {
1401
+ // Interactive deploy menu
1402
+ try {
1403
+ const { select, Separator } = await import("@inquirer/prompts");
1404
+ let deployTarget;
1405
+ try {
1406
+ deployTarget = await select({
1407
+ message: "Deploy wispy to:",
1408
+ choices: [
1409
+ { name: "VPS (SSH + systemd)", value: "vps" },
1410
+ { name: "Docker (Dockerfile + compose)", value: "docker" },
1411
+ { name: "Railway", value: "railway" },
1412
+ { name: "Fly.io", value: "fly" },
1413
+ { name: "Render", value: "render" },
1414
+ { name: "Modal (serverless)", value: "modal" },
1415
+ { name: "Daytona", value: "daytona" },
1416
+ new Separator("──────────"),
1417
+ { name: "Generate all configs (deploy init)", value: "init" },
1418
+ { name: "Check remote status", value: "status-check" },
1419
+ ],
1420
+ });
1421
+ } catch (e) {
1422
+ if (e.name === "ExitPromptError") { process.exit(130); }
1423
+ throw e;
1424
+ }
1425
+
1426
+ if (deployTarget === "vps") {
1427
+ const { input } = await import("@inquirer/prompts");
1428
+ let target;
1429
+ try { target = await input({ message: "SSH target (user@host):" }); }
1430
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
1431
+ if (target && target.trim()) {
1432
+ try { await dm.deployVPS({ target: target.trim() }); }
1433
+ catch (err) { console.error(_red(`\n❌ Deploy failed: ${err.message}`)); process.exit(1); }
1434
+ }
1435
+ } else if (deployTarget === "docker") {
1436
+ const created = await dm.init(process.cwd());
1437
+ for (const f of created) console.log(` ${f.includes("skipped") ? "⏭️ " : "✅"} ${f}`);
1438
+ console.log(_dim("\n Next: docker-compose up -d\n"));
1439
+ } else if (deployTarget === "railway") {
1440
+ process.stdout.write(dm.generateRailwayConfig() + "\n");
1441
+ } else if (deployTarget === "fly") {
1442
+ process.stdout.write(dm.generateFlyConfig());
1443
+ } else if (deployTarget === "render") {
1444
+ process.stdout.write(dm.generateRenderConfig());
1445
+ } else if (deployTarget === "modal") {
1446
+ process.stdout.write(dm.generateModalConfig());
1447
+ } else if (deployTarget === "daytona") {
1448
+ const { mkdir: mkdDir, writeFile: wfDir } = await import("node:fs/promises");
1449
+ const daytonaDir = path.join(process.cwd(), ".daytona");
1450
+ await mkdDir(daytonaDir, { recursive: true });
1451
+ const daytonaConfigPath = path.join(daytonaDir, "config.yaml");
1452
+ let exists = false;
1453
+ try { await (await import("node:fs/promises")).access(daytonaConfigPath); exists = true; } catch {}
1454
+ if (!exists) {
1455
+ await wfDir(daytonaConfigPath, dm.generateDaytonaConfig(), "utf8");
1456
+ console.log(_green(`✅ Created .daytona/config.yaml`));
1457
+ } else {
1458
+ console.log(_yellow(`⏭️ .daytona/config.yaml already exists`));
1459
+ }
1460
+ } else if (deployTarget === "init") {
1461
+ console.log("\n🌿 Initializing wispy deploy configs...\n");
1462
+ const created = await dm.init(process.cwd());
1463
+ for (const f of created) console.log(` ${f.includes("skipped") ? "⏭️ " : "✅"} ${f}`);
1464
+ } else if (deployTarget === "status-check") {
1465
+ const { input } = await import("@inquirer/prompts");
1466
+ let url;
1467
+ try { url = await input({ message: "Remote URL to check:" }); }
1468
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
1469
+ if (url && url.trim()) {
1470
+ process.stdout.write(`\n📡 Checking ${_cyan(url.trim())}... `);
1471
+ const status = await dm.checkRemote(url.trim());
1472
+ if (status.alive) {
1473
+ console.log(_green("✓ alive"));
1474
+ if (status.version) console.log(` Version: ${status.version}`);
1475
+ } else {
1476
+ console.log(_red("✗ unreachable"));
1477
+ }
1478
+ }
1479
+ }
1480
+ process.exit(0);
1481
+ } catch (e) {
1482
+ if (e.name === "ExitPromptError") { process.exit(130); }
1483
+ // Fall through to help
1484
+ }
1485
+ }
1486
+
1259
1487
  if (sub === "dockerfile") {
1260
1488
  process.stdout.write(dm.generateDockerfile());
1261
1489
  process.exit(0);
@@ -1399,7 +1627,56 @@ if (args[0] === "migrate") {
1399
1627
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1400
1628
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
1401
1629
 
1402
- if (!sub || sub === "openclaw") {
1630
+ if (!sub) {
1631
+ // Interactive migrate menu
1632
+ try {
1633
+ const { select } = await import("@inquirer/prompts");
1634
+ const { homedir } = await import("node:os");
1635
+ const { existsSync } = await import("node:fs");
1636
+ const { join } = await import("node:path");
1637
+
1638
+ const hasOpenClaw = existsSync(join(homedir(), ".openclaw"));
1639
+ const choices = [
1640
+ {
1641
+ name: `OpenClaw${hasOpenClaw ? green(" (detected at ~/.openclaw)") : dim(" (not found)")}`,
1642
+ value: "openclaw",
1643
+ },
1644
+ { name: "Hermes Agent", value: "hermes" },
1645
+ { name: "Manual import (JSON/YAML)", value: "manual" },
1646
+ ];
1647
+
1648
+ let migrateFrom;
1649
+ try {
1650
+ migrateFrom = await select({ message: "Migrate from:", choices });
1651
+ } catch (e) {
1652
+ if (e.name === "ExitPromptError") { process.exit(130); }
1653
+ throw e;
1654
+ }
1655
+
1656
+ if (migrateFrom === "openclaw") {
1657
+ args[1] = "openclaw";
1658
+ // Fall through
1659
+ } else if (migrateFrom === "hermes") {
1660
+ console.log(dim("\nHermes migration coming soon. Use manual import for now.\n"));
1661
+ process.exit(0);
1662
+ } else if (migrateFrom === "manual") {
1663
+ const { input } = await import("@inquirer/prompts");
1664
+ let filePath;
1665
+ try { filePath = await input({ message: "Path to JSON/YAML file:" }); }
1666
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
1667
+ console.log(dim(`\nManual import from ${filePath} — coming soon.\n`));
1668
+ process.exit(0);
1669
+ }
1670
+ } catch (e) {
1671
+ if (e.name === "ExitPromptError") { process.exit(130); }
1672
+ args[1] = "openclaw";
1673
+ }
1674
+ }
1675
+
1676
+ const sub2 = args[1]; // re-read after interactive
1677
+
1678
+ if (!sub || sub === "openclaw" || sub2 === "openclaw") {
1679
+ const subToUse = sub || sub2;
1403
1680
  console.log(`\n🌿 ${bold("Wispy Migration Tool")} ${dim("— from OpenClaw")}\n`);
1404
1681
  if (dryRun) console.log(yellow(" [DRY RUN — no files will be written]\n"));
1405
1682
  if (memoryOnly) console.log(dim(" [memory-only mode]\n"));
@@ -1459,6 +1736,108 @@ if (args[0] === "cron") {
1459
1736
  const cron = new CronManager(WISPY_DIR, engine);
1460
1737
  await cron.init();
1461
1738
 
1739
+ if (!sub) {
1740
+ // Interactive cron menu
1741
+ try {
1742
+ const { select, Separator } = await import("@inquirer/prompts");
1743
+ const jobs = cron.list();
1744
+
1745
+ const choices = [];
1746
+ if (jobs.length === 0) {
1747
+ choices.push(new Separator(_dim("No jobs configured.")));
1748
+ } else {
1749
+ for (const j of jobs) {
1750
+ const schedStr = j.schedule.kind === "cron" ? j.schedule.expr
1751
+ : j.schedule.kind === "every" ? `every ${j.schedule.ms / 60000}min`
1752
+ : `at ${j.schedule.time}`;
1753
+ const lastRun = j.lastRun ? `last: ${_dim(formatCronRelative(j.lastRun))} ✓` : _dim("never run");
1754
+ choices.push({
1755
+ name: `${j.name} — ${schedStr} — ${lastRun}`,
1756
+ value: { type: "job", id: j.id, name: j.name },
1757
+ short: j.name,
1758
+ });
1759
+ }
1760
+ }
1761
+ choices.push(new Separator("──────────"));
1762
+ choices.push({ name: "Add a new job", value: { type: "add" } });
1763
+ choices.push({ name: "Start scheduler", value: { type: "start" } });
1764
+
1765
+ function formatCronRelative(ts) {
1766
+ if (!ts) return "never";
1767
+ const diffMs = Date.now() - new Date(ts).getTime();
1768
+ const diffMin = Math.floor(diffMs / 60000);
1769
+ const diffH = Math.floor(diffMin / 60);
1770
+ if (diffMin < 1) return "just now";
1771
+ if (diffMin < 60) return `${diffMin}min ago`;
1772
+ if (diffH < 24) return `${diffH}hr ago`;
1773
+ return `${Math.floor(diffH / 24)}d ago`;
1774
+ }
1775
+
1776
+ let answer;
1777
+ try {
1778
+ answer = await select({ message: "Cron jobs:", choices });
1779
+ } catch (e) {
1780
+ if (e.name === "ExitPromptError") { engine.destroy?.(); process.exit(130); }
1781
+ throw e;
1782
+ }
1783
+
1784
+ if (answer.type === "add") {
1785
+ // fall through to the "add" sub-command handler below
1786
+ args[1] = "add";
1787
+ } else if (answer.type === "start") {
1788
+ args[1] = "start";
1789
+ } else if (answer.type === "job") {
1790
+ // Job sub-menu
1791
+ let jobAction;
1792
+ try {
1793
+ jobAction = await select({
1794
+ message: `${answer.name}:`,
1795
+ choices: [
1796
+ { name: "Run now", value: "run" },
1797
+ { name: "Edit (not yet implemented)", value: "edit" },
1798
+ { name: "Remove", value: "remove" },
1799
+ { name: "View history", value: "history" },
1800
+ ],
1801
+ });
1802
+ } catch (e) {
1803
+ if (e.name === "ExitPromptError") { engine.destroy?.(); process.exit(130); }
1804
+ throw e;
1805
+ }
1806
+ if (jobAction === "run") {
1807
+ console.log(`🌿 Running job: ${answer.name}...`);
1808
+ const result = await cron.runNow(answer.id);
1809
+ console.log(result.output ?? result.error);
1810
+ } else if (jobAction === "remove") {
1811
+ const { confirm } = await import("@inquirer/prompts");
1812
+ let ok;
1813
+ try { ok = await confirm({ message: `Remove job '${answer.name}'?`, default: false }); }
1814
+ catch (e) { if (e.name === "ExitPromptError") { engine.destroy?.(); process.exit(130); } throw e; }
1815
+ if (ok) {
1816
+ await cron.remove(answer.id);
1817
+ console.log(_green(`✅ Removed job: ${answer.name}`));
1818
+ }
1819
+ } else if (jobAction === "history") {
1820
+ const history = await cron.getHistory(answer.id);
1821
+ console.log(`\n📋 History for "${answer.name}" (last ${history.length} runs):\n`);
1822
+ for (const h of history) {
1823
+ const icon = h.status === "success" ? "✅" : "❌";
1824
+ console.log(` ${icon} ${new Date(h.startedAt).toLocaleString()}`);
1825
+ console.log(` ${h.output?.slice(0, 100) ?? h.error ?? ""}`);
1826
+ }
1827
+ if (history.length === 0) console.log(" No runs yet.");
1828
+ } else if (jobAction === "edit") {
1829
+ console.log(_dim("Edit via: wispy cron add (then remove the old one)"));
1830
+ }
1831
+ engine.destroy?.();
1832
+ process.exit(0);
1833
+ }
1834
+ } catch (e) {
1835
+ if (e.name === "ExitPromptError") { engine.destroy?.(); process.exit(130); }
1836
+ // Fallback to list
1837
+ args[1] = "list";
1838
+ }
1839
+ }
1840
+
1462
1841
  if (!sub || sub === "list") {
1463
1842
  const jobs = cron.list();
1464
1843
  if (jobs.length === 0) {
@@ -1605,6 +1984,68 @@ if (args[0] === "audit" || args[0] === "log") {
1605
1984
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
1606
1985
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
1607
1986
 
1987
+ // Interactive audit menu when no subcommand
1988
+ if (!sub) {
1989
+ try {
1990
+ const { select } = await import("@inquirer/prompts");
1991
+ let auditAction;
1992
+ try {
1993
+ auditAction = await select({
1994
+ message: "Audit log:",
1995
+ choices: [
1996
+ { name: "View recent events", value: "recent" },
1997
+ { name: "Filter by tool", value: "by-tool" },
1998
+ { name: "Filter by session", value: "by-session" },
1999
+ { name: "Today's events only", value: "today" },
2000
+ { name: "Replay a session", value: "replay" },
2001
+ { name: "Export as markdown", value: "export-md" },
2002
+ ],
2003
+ });
2004
+ } catch (e) {
2005
+ if (e.name === "ExitPromptError") { process.exit(130); }
2006
+ throw e;
2007
+ }
2008
+
2009
+ if (auditAction === "recent") {
2010
+ // Fall through with no filter
2011
+ } else if (auditAction === "by-tool") {
2012
+ const { input } = await import("@inquirer/prompts");
2013
+ let tool;
2014
+ try { tool = await input({ message: "Tool name:" }); }
2015
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
2016
+ if (tool && tool.trim()) args.push("--tool", tool.trim());
2017
+ } else if (auditAction === "by-session") {
2018
+ const { input } = await import("@inquirer/prompts");
2019
+ let sid;
2020
+ try { sid = await input({ message: "Session ID:" }); }
2021
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
2022
+ if (sid && sid.trim()) args.push("--session", sid.trim());
2023
+ } else if (auditAction === "today") {
2024
+ args.push("--today");
2025
+ } else if (auditAction === "replay") {
2026
+ const { input } = await import("@inquirer/prompts");
2027
+ let sid;
2028
+ try { sid = await input({ message: "Session ID to replay:" }); }
2029
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
2030
+ if (sid && sid.trim()) {
2031
+ args[1] = "replay";
2032
+ args[2] = sid.trim();
2033
+ }
2034
+ } else if (auditAction === "export-md") {
2035
+ const content = await audit.exportMarkdown();
2036
+ const ts = new Date().toISOString().slice(0, 10);
2037
+ const outFile = `wispy-audit-${ts}.md`;
2038
+ const { writeFile: wf } = await import("node:fs/promises");
2039
+ await wf(outFile, content, "utf8");
2040
+ console.log(green(`✅ Exported to ${outFile}`));
2041
+ process.exit(0);
2042
+ }
2043
+ } catch (e) {
2044
+ if (e.name === "ExitPromptError") { process.exit(130); }
2045
+ // Fall through to regular display
2046
+ }
2047
+ }
2048
+
1608
2049
  function formatEvent(evt) {
1609
2050
  const ts = new Date(evt.timestamp).toLocaleTimeString();
1610
2051
  const icons = {
@@ -1766,6 +2207,95 @@ if (args[0] === "node") {
1766
2207
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
1767
2208
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
1768
2209
 
2210
+ if (!sub) {
2211
+ // Interactive node menu
2212
+ try {
2213
+ const { select, Separator } = await import("@inquirer/prompts");
2214
+ const nodeList = await nodes.list();
2215
+
2216
+ const choices = [];
2217
+ if (nodeList.length === 0) {
2218
+ choices.push(new Separator(dim("No nodes connected.")));
2219
+ } else {
2220
+ const results = await nodes.status().catch(() => []);
2221
+ for (const n of nodeList) {
2222
+ const statusInfo = results.find(r => r.id === n.id);
2223
+ const alive = statusInfo?.alive;
2224
+ const statusStr = alive ? green("● online") : dim("● offline");
2225
+ choices.push({
2226
+ name: `${n.name} — ${n.host}:${n.port} — ${statusStr}`,
2227
+ value: { type: "node", id: n.id, name: n.name },
2228
+ short: n.name,
2229
+ });
2230
+ }
2231
+ }
2232
+ choices.push(new Separator("──────────"));
2233
+ choices.push({ name: "Pair a new node (generate code)", value: { type: "pair" } });
2234
+ choices.push({ name: "Connect to a node", value: { type: "connect" } });
2235
+
2236
+ let answer;
2237
+ try {
2238
+ answer = await select({ message: "Nodes:", choices });
2239
+ } catch (e) {
2240
+ if (e.name === "ExitPromptError") { process.exit(130); }
2241
+ throw e;
2242
+ }
2243
+
2244
+ if (answer.type === "pair") {
2245
+ const code = await nodes.generatePairCode();
2246
+ console.log(`\n🔗 Pairing Code: ${bold(green(code))}\n`);
2247
+ console.log(` This code expires in 1 hour.`);
2248
+ console.log(`\n On the remote machine:\n ${cyan(`wispy node connect ${code} --url http://localhost:18790`)}\n`);
2249
+ } else if (answer.type === "connect") {
2250
+ const { input } = await import("@inquirer/prompts");
2251
+ let code, url;
2252
+ try {
2253
+ code = await input({ message: "Pairing code:" });
2254
+ url = await input({ message: "Server URL:", default: "http://localhost:18790" });
2255
+ } catch (e) {
2256
+ if (e.name === "ExitPromptError") { process.exit(130); }
2257
+ throw e;
2258
+ }
2259
+ if (code && code.trim()) args[1] = "connect", args[2] = code.trim(), args.push("--url"), args.push(url || "http://localhost:18790");
2260
+ else process.exit(0);
2261
+ } else if (answer.type === "node") {
2262
+ let nodeAction;
2263
+ try {
2264
+ nodeAction = await select({
2265
+ message: `${answer.name}:`,
2266
+ choices: [
2267
+ { name: "Ping", value: "ping" },
2268
+ { name: "Remove", value: "remove" },
2269
+ { name: "Execute command", value: "exec" },
2270
+ ],
2271
+ });
2272
+ } catch (e) {
2273
+ if (e.name === "ExitPromptError") { process.exit(130); }
2274
+ throw e;
2275
+ }
2276
+ if (nodeAction === "ping") {
2277
+ const results = await nodes.status();
2278
+ const r = results.find(x => x.id === answer.id);
2279
+ if (r?.alive) console.log(green(`● ${answer.name} — alive (${r.latency ?? "?"}ms)`));
2280
+ else console.log(red(`● ${answer.name} — unreachable`));
2281
+ } else if (nodeAction === "remove") {
2282
+ const { confirm } = await import("@inquirer/prompts");
2283
+ let ok;
2284
+ try { ok = await confirm({ message: `Remove node '${answer.name}'?`, default: false }); }
2285
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
2286
+ if (ok) { await nodes.remove(answer.id); console.log(green(`✅ Removed node: ${answer.name}`)); }
2287
+ } else if (nodeAction === "exec") {
2288
+ console.log(dim("Execute via: wispy node exec <id> <command> (coming soon)"));
2289
+ }
2290
+ process.exit(0);
2291
+ }
2292
+ process.exit(0);
2293
+ } catch (e) {
2294
+ if (e.name === "ExitPromptError") { process.exit(130); }
2295
+ // Fall through to help
2296
+ }
2297
+ }
2298
+
1769
2299
  if (sub === "pair") {
1770
2300
  const code = await nodes.generatePairCode();
1771
2301
  console.log(`\n🔗 Pairing Code: ${bold(green(code))}\n`);
@@ -1896,6 +2426,107 @@ if (args[0] === "channel") {
1896
2426
  await channelList();
1897
2427
  } else if (sub === "test" && name) {
1898
2428
  await channelTest(name);
2429
+ } else if (!sub) {
2430
+ // Interactive channel menu
2431
+ try {
2432
+ const { select, Separator } = await import("@inquirer/prompts");
2433
+ const { homedir } = await import("node:os");
2434
+ const { readFile } = await import("node:fs/promises");
2435
+ const { join } = await import("node:path");
2436
+
2437
+ const channelDefs = [
2438
+ { key: "telegram", label: "Telegram" },
2439
+ { key: "discord", label: "Discord" },
2440
+ { key: "slack", label: "Slack" },
2441
+ { key: "whatsapp", label: "WhatsApp" },
2442
+ { key: "signal", label: "Signal" },
2443
+ { key: "email", label: "Email" },
2444
+ ];
2445
+
2446
+ // Check which are configured
2447
+ const configPath = join(homedir(), ".wispy", "config.json");
2448
+ let cfg = {};
2449
+ try { cfg = JSON.parse(await readFile(configPath, "utf8")); } catch {}
2450
+ const channels = cfg.channels ?? {};
2451
+
2452
+ const choices = channelDefs.map(ch => {
2453
+ const configured = !!channels[ch.key];
2454
+ const status = configured ? _green("connected ✓") : _dim("not configured");
2455
+ return {
2456
+ name: `${ch.label} — ${status}`,
2457
+ value: { key: ch.key, configured, label: ch.label },
2458
+ short: ch.label,
2459
+ };
2460
+ });
2461
+ choices.push(new Separator("──────────"));
2462
+ choices.push({ name: "Start all bots (--serve)", value: { key: "__serve__" } });
2463
+
2464
+ let answer;
2465
+ try {
2466
+ answer = await select({ message: "Channels:", choices });
2467
+ } catch (e) {
2468
+ if (e.name === "ExitPromptError") { process.exit(130); }
2469
+ throw e;
2470
+ }
2471
+
2472
+ if (answer.key === "__serve__") {
2473
+ // Re-run with --serve
2474
+ const { spawn } = await import("node:child_process");
2475
+ spawn(process.execPath, [process.argv[1], "--serve"], { stdio: "inherit" })
2476
+ .on("exit", code => process.exit(code ?? 0));
2477
+ await new Promise(() => {});
2478
+ } else if (!answer.configured) {
2479
+ await channelSetup(answer.key);
2480
+ } else {
2481
+ // Configured channel actions
2482
+ let channelAction;
2483
+ try {
2484
+ channelAction = await select({
2485
+ message: `${answer.label}:`,
2486
+ choices: [
2487
+ { name: "Test connection", value: "test" },
2488
+ { name: "Reconfigure", value: "setup" },
2489
+ { name: "Disconnect", value: "disconnect" },
2490
+ ],
2491
+ });
2492
+ } catch (e) {
2493
+ if (e.name === "ExitPromptError") { process.exit(130); }
2494
+ throw e;
2495
+ }
2496
+ if (channelAction === "test") {
2497
+ await channelTest(answer.key);
2498
+ } else if (channelAction === "setup") {
2499
+ await channelSetup(answer.key);
2500
+ } else if (channelAction === "disconnect") {
2501
+ const { confirm } = await import("@inquirer/prompts");
2502
+ let ok;
2503
+ try { ok = await confirm({ message: `Disconnect ${answer.label}?`, default: false }); }
2504
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
2505
+ if (ok) {
2506
+ const { loadConfig, saveConfig } = await import(path.join(__dirname, "..", "core", "config.mjs"));
2507
+ const c = await loadConfig();
2508
+ if (c.channels) delete c.channels[answer.key];
2509
+ await saveConfig(c);
2510
+ console.log(_green(`✅ Disconnected ${answer.label}`));
2511
+ }
2512
+ }
2513
+ }
2514
+ } catch (e) {
2515
+ if (e.name === "ExitPromptError") { process.exit(130); }
2516
+ // Fallback help
2517
+ console.log(`
2518
+ 🌿 Wispy Channel Commands:
2519
+
2520
+ wispy channel setup telegram — interactive Telegram bot setup
2521
+ wispy channel setup discord — interactive Discord bot setup
2522
+ wispy channel setup slack — interactive Slack bot setup
2523
+ wispy channel setup whatsapp — WhatsApp setup
2524
+ wispy channel setup email — Email setup
2525
+ wispy channel list — show configured channels
2526
+ wispy channel test <name> — test a channel connection
2527
+ wispy --serve — start all configured channel bots
2528
+ `);
2529
+ }
1899
2530
  } else {
1900
2531
  console.log(`
1901
2532
  🌿 Wispy Channel Commands:
@@ -1931,25 +2562,124 @@ if (args[0] === "auth") {
1931
2562
 
1932
2563
  const auth = new AuthManager(WISPY_DIR);
1933
2564
 
1934
- // wispy auth — show status for all providers
2565
+ // wispy auth — interactive menu
1935
2566
  if (!sub) {
1936
- const statuses = await auth.allStatus();
1937
- if (statuses.length === 0) {
1938
- console.log(_dim("\n No saved auth tokens. Run: wispy auth <provider>\n"));
1939
- } else {
1940
- console.log(`\n${_bold("🔑 Auth Status")}\n`);
1941
- for (const s of statuses) {
1942
- const expiryStr = s.expiresAt ? _dim(` (expires ${new Date(s.expiresAt).toLocaleString()})`) : "";
1943
- const statusIcon = s.expired ? _yellow("⚠️ expired") : _green("✅ valid");
1944
- console.log(` ${_cyan(s.provider.padEnd(20))} ${s.type.padEnd(8)} ${statusIcon}${expiryStr}`);
2567
+ try {
2568
+ const { select, Separator } = await import("@inquirer/prompts");
2569
+ const statuses = await auth.allStatus().catch(() => []);
2570
+
2571
+ // All known providers + configured ones
2572
+ const KNOWN_PROVIDERS = ["google", "anthropic", "openai", "xai", "github-copilot"];
2573
+ const configuredMap = {};
2574
+ for (const s of statuses) configuredMap[s.provider] = s;
2575
+
2576
+ const { loadConfig } = await import(path.join(__dirname, "..", "core", "config.mjs"));
2577
+ const cfg = await loadConfig();
2578
+ const activeProviders = cfg.providers ? Object.keys(cfg.providers) : (cfg.provider ? [cfg.provider] : []);
2579
+
2580
+ const allProviders = [...new Set([...activeProviders, ...statuses.map(s => s.provider), ...KNOWN_PROVIDERS])];
2581
+
2582
+ const choices = allProviders.map(p => {
2583
+ const s = configuredMap[p];
2584
+ const status = s ? (s.expired ? _yellow("⚠️ expired") : _green("API key ✓")) : _dim("not configured");
2585
+ return {
2586
+ name: `${p} — ${status}`,
2587
+ value: { provider: p, configured: !!s, expired: s?.expired },
2588
+ short: p,
2589
+ };
2590
+ });
2591
+ choices.push(new Separator("──────────"));
2592
+ choices.push({ name: "Add new provider auth", value: { provider: "__new__" } });
2593
+ choices.push({ name: "Refresh expired tokens", value: { provider: "__refresh_all__" } });
2594
+
2595
+ let answer;
2596
+ try {
2597
+ answer = await select({ message: "Authentication:", choices });
2598
+ } catch (e) {
2599
+ if (e.name === "ExitPromptError") { process.exit(130); }
2600
+ throw e;
1945
2601
  }
1946
- console.log("");
1947
- console.log(_dim(" wispy auth <provider> — re-authenticate"));
1948
- console.log(_dim(" wispy auth refresh <provider> — refresh token"));
1949
- console.log(_dim(" wispy auth revoke <provider> — remove saved token"));
2602
+
2603
+ if (answer.provider === "__new__") {
2604
+ const { input } = await import("@inquirer/prompts");
2605
+ let prov;
2606
+ try { prov = await input({ message: "Provider name (e.g. github-copilot):" }); }
2607
+ catch (e) { if (e.name === "ExitPromptError") { process.exit(130); } throw e; }
2608
+ if (prov && prov.trim()) {
2609
+ // Treat as wispy auth <provider>
2610
+ const provName = prov.trim();
2611
+ if (provName === "github-copilot") {
2612
+ console.log(`\n🔑 ${_bold("GitHub Copilot")} — OAuth sign-in\n`);
2613
+ try {
2614
+ const result = await auth.oauth("github-copilot");
2615
+ if (result.valid) console.log(_green(`✅ GitHub Copilot authenticated!\n`));
2616
+ } catch (err) { console.error(_red(`\n❌ ${err.message}\n`)); }
2617
+ } else {
2618
+ console.log(_dim(` API key auth: set env var for ${provName} and run 'wispy setup'\n`));
2619
+ }
2620
+ }
2621
+ } else if (answer.provider === "__refresh_all__") {
2622
+ const expired = statuses.filter(s => s.expired);
2623
+ if (expired.length === 0) {
2624
+ console.log(_dim("No expired tokens found.\n"));
2625
+ } else {
2626
+ for (const s of expired) {
2627
+ process.stdout.write(`Refreshing ${s.provider}... `);
2628
+ try { await auth.refreshToken(s.provider); console.log(_green("✓")); }
2629
+ catch (e) { console.log(_red(`✗ ${e.message}`)); }
2630
+ }
2631
+ }
2632
+ } else if (answer.configured) {
2633
+ let authAction;
2634
+ try {
2635
+ authAction = await select({
2636
+ message: `${answer.provider}:`,
2637
+ choices: [
2638
+ { name: "Refresh token", value: "refresh" },
2639
+ { name: "Revoke / remove", value: "revoke" },
2640
+ ],
2641
+ });
2642
+ } catch (e) {
2643
+ if (e.name === "ExitPromptError") { process.exit(130); }
2644
+ throw e;
2645
+ }
2646
+ if (authAction === "refresh") {
2647
+ try { await auth.refreshToken(answer.provider); console.log(_green(`✅ Token refreshed for ${answer.provider}\n`)); }
2648
+ catch (err) { console.error(_red(`❌ ${err.message}\n`)); }
2649
+ } else if (authAction === "revoke") {
2650
+ await auth.revokeToken(answer.provider);
2651
+ console.log(_green(`✅ Revoked auth for ${answer.provider}\n`));
2652
+ }
2653
+ } else {
2654
+ // Setup flow for unconfigured provider
2655
+ if (answer.provider === "github-copilot") {
2656
+ console.log(`\n🔑 ${_bold("GitHub Copilot")} — OAuth sign-in\n`);
2657
+ try {
2658
+ const result = await auth.oauth("github-copilot");
2659
+ if (result.valid) console.log(_green(`✅ GitHub Copilot authenticated!\n`));
2660
+ } catch (err) { console.error(_red(`\n❌ ${err.message}\n`)); }
2661
+ } else {
2662
+ console.log(_dim(`\n Set env var for ${answer.provider} and run 'wispy setup provider'\n`));
2663
+ }
2664
+ }
2665
+ process.exit(0);
2666
+ } catch (e) {
2667
+ if (e.name === "ExitPromptError") { process.exit(130); }
2668
+ // Fallback: show status
2669
+ const statuses = await auth.allStatus().catch(() => []);
2670
+ if (statuses.length === 0) {
2671
+ console.log(_dim("\n No saved auth tokens. Run: wispy auth <provider>\n"));
2672
+ } else {
2673
+ console.log(`\n${_bold("🔑 Auth Status")}\n`);
2674
+ for (const s of statuses) {
2675
+ const expiryStr = s.expiresAt ? _dim(` (expires ${new Date(s.expiresAt).toLocaleString()})`) : "";
2676
+ const statusIcon = s.expired ? _yellow("⚠️ expired") : _green("✅ valid");
2677
+ console.log(` ${_cyan(s.provider.padEnd(20))} ${s.type.padEnd(8)} ${statusIcon}${expiryStr}`);
2678
+ }
2679
+ console.log("");
2680
+ }
2681
+ process.exit(0);
1950
2682
  }
1951
- console.log("");
1952
- process.exit(0);
1953
2683
  }
1954
2684
 
1955
2685
  // wispy auth refresh <provider>
@@ -2132,12 +2862,56 @@ if (isInteractiveStart) {
2132
2862
  path.join(__dirname, "..", "core", "config.mjs")
2133
2863
  );
2134
2864
  if (await isFirstRun()) {
2135
- const { OnboardingWizard } = await import(
2136
- path.join(__dirname, "..", "core", "onboarding.mjs")
2137
- );
2138
- const wizard = new OnboardingWizard();
2139
- await wizard.run();
2140
- // After onboarding, continue to REPL or TUI
2865
+ // Show welcome interactive menu before onboarding
2866
+ if (args.length === 0) {
2867
+ try {
2868
+ const { select } = await import("@inquirer/prompts");
2869
+ let welcomeAction;
2870
+ try {
2871
+ welcomeAction = await select({
2872
+ message: "Welcome to Wispy! What would you like to do?",
2873
+ choices: [
2874
+ { name: "Start chatting (REPL)", value: "repl" },
2875
+ { name: "Open workspace (TUI)", value: "tui" },
2876
+ { name: "Set up wispy", value: "setup" },
2877
+ { name: "Run diagnostics", value: "doctor" },
2878
+ ],
2879
+ });
2880
+ } catch (e) {
2881
+ if (e.name === "ExitPromptError") { process.exit(130); }
2882
+ throw e;
2883
+ }
2884
+ if (welcomeAction === "tui") {
2885
+ args.push("tui");
2886
+ } else if (welcomeAction === "setup") {
2887
+ const { OnboardingWizard } = await import(path.join(__dirname, "..", "core", "onboarding.mjs"));
2888
+ const wizard = new OnboardingWizard();
2889
+ await wizard.run();
2890
+ process.exit(0);
2891
+ } else if (welcomeAction === "doctor") {
2892
+ // Re-exec with doctor
2893
+ const { spawn: sp } = await import("node:child_process");
2894
+ sp(process.execPath, [process.argv[1], "doctor"], { stdio: "inherit" })
2895
+ .on("exit", code => process.exit(code ?? 0));
2896
+ await new Promise(() => {});
2897
+ }
2898
+ // "repl" falls through to REPL below
2899
+ } catch (e) {
2900
+ if (e.name !== "ExitPromptError") {
2901
+ // Run normal onboarding if welcome menu fails
2902
+ const { OnboardingWizard } = await import(path.join(__dirname, "..", "core", "onboarding.mjs"));
2903
+ const wizard = new OnboardingWizard();
2904
+ await wizard.run();
2905
+ }
2906
+ }
2907
+ } else {
2908
+ const { OnboardingWizard } = await import(
2909
+ path.join(__dirname, "..", "core", "onboarding.mjs")
2910
+ );
2911
+ const wizard = new OnboardingWizard();
2912
+ await wizard.run();
2913
+ // After onboarding, continue to REPL or TUI
2914
+ }
2141
2915
  }
2142
2916
  } catch {
2143
2917
  // If onboarding fails for any reason, continue normally
@@ -204,6 +204,123 @@ export async function cmdImproveSkill(name, feedback) {
204
204
  export async function handleSkillCommand(args) {
205
205
  const sub = args[1];
206
206
 
207
+ if (!sub) {
208
+ // Interactive menu
209
+ try {
210
+ const { select, Separator, input } = await import("@inquirer/prompts");
211
+ const skills = await listSkills();
212
+
213
+ const choices = [];
214
+ if (skills.length > 0) {
215
+ for (const s of skills) {
216
+ const used = s.timesUsed > 0 ? ` — used ${s.timesUsed} times` : "";
217
+ choices.push({
218
+ name: `${s.name}${used}`,
219
+ value: { type: "skill", name: s.name },
220
+ short: s.name,
221
+ });
222
+ }
223
+ }
224
+ choices.push(new Separator("──────────"));
225
+ choices.push({ name: "Create skill from last conversation", value: { type: "create" } });
226
+ choices.push({ name: "Import skill", value: { type: "import" } });
227
+
228
+ let answer;
229
+ try {
230
+ answer = await select({ message: "Skills:", choices });
231
+ } catch (e) {
232
+ if (e.name === "ExitPromptError") { process.exit(130); }
233
+ throw e;
234
+ }
235
+
236
+ if (answer.type === "create") {
237
+ let name;
238
+ try {
239
+ name = await input({ message: "Skill name:" });
240
+ } catch (e) {
241
+ if (e.name === "ExitPromptError") return;
242
+ throw e;
243
+ }
244
+ if (name && name.trim()) await cmdTeach(name.trim());
245
+ } else if (answer.type === "import") {
246
+ console.log(dim("Import via: wispy skill import <path> (coming soon)"));
247
+ } else if (answer.type === "skill") {
248
+ // Sub-menu for selected skill
249
+ let skillAction;
250
+ try {
251
+ skillAction = await select({
252
+ message: `${answer.name}:`,
253
+ choices: [
254
+ { name: "Run now", value: "run" },
255
+ { name: "View details", value: "view" },
256
+ { name: "Improve", value: "improve" },
257
+ { name: "Delete", value: "delete" },
258
+ ],
259
+ });
260
+ } catch (e) {
261
+ if (e.name === "ExitPromptError") return;
262
+ throw e;
263
+ }
264
+ if (skillAction === "run") {
265
+ await cmdSkillRun(answer.name);
266
+ } else if (skillAction === "view") {
267
+ const skill = await (async () => {
268
+ const { readFile } = await import("node:fs/promises");
269
+ const { join } = await import("node:path");
270
+ const { homedir } = await import("node:os");
271
+ const skillsDir = join(homedir(), ".wispy", "skills");
272
+ try {
273
+ return JSON.parse(await readFile(join(skillsDir, `${answer.name}.json`), "utf8"));
274
+ } catch { return null; }
275
+ })();
276
+ if (skill) {
277
+ console.log(`\n${bold(skill.name)} v${skill.version ?? 1}`);
278
+ console.log(dim(` ${skill.description ?? ""}`));
279
+ console.log(dim(` Prompt: ${skill.prompt?.slice(0, 120) ?? ""}`));
280
+ console.log(dim(` Used: ${skill.timesUsed ?? 0}x`));
281
+ if (skill.tags?.length > 0) console.log(dim(` Tags: ${skill.tags.join(", ")}`));
282
+ console.log("");
283
+ }
284
+ } else if (skillAction === "improve") {
285
+ let feedback;
286
+ try {
287
+ const { input: inp } = await import("@inquirer/prompts");
288
+ feedback = await inp({ message: "Improvement notes:" });
289
+ } catch (e) {
290
+ if (e.name === "ExitPromptError") return;
291
+ throw e;
292
+ }
293
+ if (feedback && feedback.trim()) await cmdImproveSkill(answer.name, feedback.trim());
294
+ } else if (skillAction === "delete") {
295
+ const { confirm } = await import("@inquirer/prompts");
296
+ let ok;
297
+ try {
298
+ ok = await confirm({ message: `Delete skill '${answer.name}'?`, default: false });
299
+ } catch (e) {
300
+ if (e.name === "ExitPromptError") return;
301
+ throw e;
302
+ }
303
+ if (ok) {
304
+ const { unlink } = await import("node:fs/promises");
305
+ const { join } = await import("node:path");
306
+ const { homedir } = await import("node:os");
307
+ const skillsDir = join(homedir(), ".wispy", "skills");
308
+ try {
309
+ await unlink(join(skillsDir, `${answer.name}.json`));
310
+ console.log(green(`✅ Deleted skill: ${answer.name}`));
311
+ } catch (e) {
312
+ console.log(red(`Failed to delete: ${e.message}`));
313
+ }
314
+ }
315
+ }
316
+ }
317
+ } catch (e) {
318
+ if (e.name === "ExitPromptError") { process.exit(130); }
319
+ await cmdSkillList();
320
+ }
321
+ return;
322
+ }
323
+
207
324
  if (!sub) return cmdSkillList();
208
325
  if (sub === "run") return cmdSkillRun(args[2]);
209
326
  if (sub === "list") return cmdSkillList();
@@ -322,7 +322,85 @@ export async function cmdTrustReceipt(id) {
322
322
  export async function handleTrustCommand(args) {
323
323
  const sub = args[1];
324
324
 
325
- if (!sub) return cmdTrustShow();
325
+ if (!sub) {
326
+ // Interactive menu
327
+ try {
328
+ const { select } = await import("@inquirer/prompts");
329
+ const level = await getCurrentSecurityLevel();
330
+ const preset = SECURITY_PRESETS[level] ?? SECURITY_PRESETS.balanced;
331
+
332
+ console.log(`\nCurrent level: ${preset.color(bold(preset.label))}`);
333
+ console.log(dim(` ${preset.description}\n`));
334
+
335
+ let action;
336
+ try {
337
+ action = await select({
338
+ message: "Change trust settings:",
339
+ choices: [
340
+ { name: "Set level (careful / balanced / yolo)", value: "set-level" },
341
+ { name: "View audit log", value: "log" },
342
+ { name: "View recent approvals", value: "approvals" },
343
+ { name: "Replay a session", value: "replay" },
344
+ { name: "Show permissions by tool", value: "permissions" },
345
+ ],
346
+ });
347
+ } catch (e) {
348
+ if (e.name === "ExitPromptError") { process.exit(130); }
349
+ throw e;
350
+ }
351
+
352
+ if (action === "set-level") {
353
+ let levelChoice;
354
+ try {
355
+ levelChoice = await select({
356
+ message: "Choose trust level:",
357
+ choices: [
358
+ { name: `${green("careful")} 🔒 — require approval for most operations`, value: "careful" },
359
+ { name: `${yellow("balanced")} ⚖️ — approve dangerous ops, notify writes`, value: "balanced" },
360
+ { name: `${red("yolo")} 🚀 — auto-approve everything (use with care!)`, value: "yolo" },
361
+ ],
362
+ });
363
+ } catch (e) {
364
+ if (e.name === "ExitPromptError") return;
365
+ throw e;
366
+ }
367
+ await cmdTrustLevel(levelChoice);
368
+ } else if (action === "log") {
369
+ await cmdTrustLog([]);
370
+ } else if (action === "approvals") {
371
+ await cmdTrustShow();
372
+ } else if (action === "replay") {
373
+ const { input } = await import("@inquirer/prompts");
374
+ let sid;
375
+ try {
376
+ sid = await input({ message: "Session ID to replay:" });
377
+ } catch (e) {
378
+ if (e.name === "ExitPromptError") return;
379
+ throw e;
380
+ }
381
+ if (sid && sid.trim()) await cmdTrustReplay(sid.trim());
382
+ } else if (action === "permissions") {
383
+ const { readJsonOr: rjo } = { readJsonOr: async (p, f) => { try { return JSON.parse(await (await import("node:fs/promises")).readFile(p, "utf8")); } catch { return f; } } };
384
+ const permsData = await rjo(PERMISSIONS_FILE, { policies: {} });
385
+ const policies = permsData.policies ?? {};
386
+ console.log(`\n${bold("🔐 Permissions by tool:")}\n`);
387
+ if (Object.keys(policies).length === 0) {
388
+ console.log(dim(" No custom policies. Using level defaults."));
389
+ } else {
390
+ for (const [tool, pol] of Object.entries(policies)) {
391
+ const icon = pol === "approve" ? "🔐" : pol === "notify" ? "📋" : "✅";
392
+ console.log(` ${icon} ${cyan(tool.padEnd(20))} ${pol}`);
393
+ }
394
+ }
395
+ console.log("");
396
+ }
397
+ } catch (e) {
398
+ if (e.name === "ExitPromptError") { process.exit(130); }
399
+ // Fallback to regular show
400
+ await cmdTrustShow();
401
+ }
402
+ return;
403
+ }
326
404
  if (sub === "level") return cmdTrustLevel(args[2]);
327
405
  if (sub === "log") return cmdTrustLog(args.slice(2));
328
406
  if (sub === "replay") return cmdTrustReplay(args[2]);
@@ -412,7 +412,87 @@ export async function handleWsCommand(args) {
412
412
  const sub = args[1];
413
413
 
414
414
  if (!sub) {
415
- return cmdWsList();
415
+ // Interactive menu
416
+ try {
417
+ const { select, input, Separator } = await import("@inquirer/prompts");
418
+ const workstreams = await listAllWorkstreams();
419
+
420
+ const choices = [];
421
+ if (workstreams.length > 0) {
422
+ for (const ws of workstreams) {
423
+ const marker = ws.isActive ? "● " : " ";
424
+ const last = formatRelative(ws.lastActive);
425
+ const msgs = ws.sessionCount > 0 ? ` · ${ws.sessionCount} msgs` : "";
426
+ choices.push({
427
+ name: `${marker}${ws.name}${ws.isActive ? " (active)" : ""} — ${last}${msgs}`,
428
+ value: { type: "switch", name: ws.name },
429
+ short: ws.name,
430
+ });
431
+ }
432
+ } else {
433
+ choices.push(new Separator(dim("No workstreams yet")));
434
+ }
435
+ choices.push(new Separator("──────────"));
436
+ choices.push({ name: "Create new workstream", value: { type: "new" }, short: "Create new" });
437
+ choices.push({ name: "Archive a workstream", value: { type: "archive" }, short: "Archive" });
438
+ choices.push({ name: "Delete a workstream", value: { type: "delete" }, short: "Delete" });
439
+
440
+ let answer;
441
+ try {
442
+ answer = await select({ message: "Workstreams:", choices });
443
+ } catch (e) {
444
+ if (e.name === "ExitPromptError") { process.exit(130); }
445
+ throw e;
446
+ }
447
+
448
+ if (answer.type === "switch") {
449
+ await cmdWsSwitch(answer.name);
450
+ } else if (answer.type === "new") {
451
+ let name;
452
+ try {
453
+ name = await input({ message: "New workstream name:" });
454
+ } catch (e) {
455
+ if (e.name === "ExitPromptError") return;
456
+ throw e;
457
+ }
458
+ if (name && name.trim()) await cmdWsNew(name.trim());
459
+ } else if (answer.type === "archive") {
460
+ const nonActive = workstreams.filter(w => !w.isActive);
461
+ if (nonActive.length === 0) {
462
+ console.log(dim("No other workstreams to archive."));
463
+ return;
464
+ }
465
+ const archiveChoices = nonActive.map(w => ({ name: w.name, value: w.name }));
466
+ let toArchive;
467
+ try {
468
+ toArchive = await select({ message: "Archive which workstream?", choices: archiveChoices });
469
+ } catch (e) {
470
+ if (e.name === "ExitPromptError") return;
471
+ throw e;
472
+ }
473
+ await cmdWsArchive(toArchive);
474
+ } else if (answer.type === "delete") {
475
+ const deletable = workstreams.filter(w => w.name !== "default");
476
+ if (deletable.length === 0) {
477
+ console.log(dim("No workstreams to delete (cannot delete 'default')."));
478
+ return;
479
+ }
480
+ const deleteChoices = deletable.map(w => ({ name: w.name, value: w.name }));
481
+ let toDelete;
482
+ try {
483
+ toDelete = await select({ message: "Delete which workstream?", choices: deleteChoices });
484
+ } catch (e) {
485
+ if (e.name === "ExitPromptError") return;
486
+ throw e;
487
+ }
488
+ await cmdWsDelete(toDelete);
489
+ }
490
+ } catch (e) {
491
+ if (e.name === "ExitPromptError") { process.exit(130); }
492
+ // Fallback to plain list if inquirer unavailable
493
+ await cmdWsList();
494
+ }
495
+ return;
416
496
  }
417
497
 
418
498
  if (sub === "new") return cmdWsNew(args[2]);
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wispy-tui.mjs — Workspace OS TUI for Wispy v2.0
4
+ *
5
+ * Multi-panel workspace interface:
6
+ * - Left sidebar: Workstreams, Agents, Memory, Cron, Sync
7
+ * - Main area: Chat / Overview / Agents / Memory / Audit / Settings
8
+ * - Bottom: Action Timeline bar + Input
9
+ * - Overlays: Approval dialogs, Diff views
10
+ */
11
+
12
+ import React, { useState, useEffect, useRef, useCallback, useReducer } from "react";
13
+ import { render, Box, Text, useApp, Newline, useInput, useStdout } from "ink";
14
+ import Spinner from "ink-spinner";
15
+ import TextInput from "ink-text-input";
16
+
17
+ import { COMMANDS, filterCommands } from "./command-registry.mjs";
18
+
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { readFile, writeFile, readdir, stat, mkdir } from "node:fs/promises";
22
+
23
+ import { WispyEngine, CONVERSATIONS_DIR, PROVIDERS, WISPY_DIR, MEMORY_DIR } from "../core/index.mjs";
24
+
25
+ // ─── Parse CLI args ──────────────────────────────────────────────────────────
26
+
27
+ const rawArgs = process.argv.slice(2);
28
+ const wsIdx = rawArgs.findIndex((a) => a === "-w" || a === "--workstream");
29
+ const INITIAL_WORKSTREAM =
30
+ process.env.WISPY_WORKSTREAM ??
31
+ (wsIdx !== -1 ? rawArgs[wsIdx + 1] : null) ??
32
+ "default";
33
+
34
+ // ─── Constants ───────────────────────────────────────────────────────────────
35
+
36
+ const VIEWS = ["chat", "overview", "agents", "memory", "audit", "settings"];
37
+ const SIDEBAR_WIDTH = 16;
38
+ const TIMELINE_LINES = 3;
39
+
40
+ const TOOL_ICONS = {
41
+ read_file: "⎘", write_file: "✍", file_edit: "✎", run_command: "⚙",
42
+ git: "⧉", web_search: "🔍", web_fetch: "⤓", list_directory: "☰",
43
+ spawn_subagent: "⇝", spawn_agent: "↠", memory_save: "💾",
44
+ memory_search: "🔎", memory_list: "🗂", delete_file: "🗑",
45
+ node_execute: "⨀", update_work_context: "⟳",
46
+ };
47
+
48
+ // ─── Utilities ───────────────────────────────────────────────────────────────
49
+
50
+ function fmtTime(iso) {
51
+ if (!iso) return "";
52
+ try { return new Date(iso).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }); }
53
+ catch { return ""; }
54
+ }
55
+
56
+ function fmtRelTime(iso) {
57
+ if (!iso) return "";
58
+ try {
59
+ const diff = Date.now() - new Date(iso).getTime();
60
+ if (diff < 60_000) return "just now";
61
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`;
62
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}hr ago`;
63
+ return "yesterday";
64
+ } catch { return ""; }
65
+ }
66
+
67
+ function truncate(str, n) {
68
+ if (!str) return "";
69
+ return str.length > n ? str.slice(0, n - 1) + "…" : str;
70
+ }
71
+
72
+ // ─── Markdown renderer ───────────────────────────────────────────────────────
73
+
74
+ function renderMarkdown(text, maxWidth = 60) {
75
+ // Updated renderer function
76
+ }
77
+
78
+ // ─── Updates incorporate "minimalistic" visuals for agent functionality and correct UX/lint fixes ───
79
+ const agentIcon = (status) => {
80
+ if (status === "running") return "●";
81
+ if (status === "pending") return "○";
82
+ if (status === "" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",