wispy-cli 2.0.0 → 2.0.2

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
@@ -4,7 +4,8 @@
4
4
  * Wispy CLI entry point
5
5
  *
6
6
  * Flags:
7
- * --tui Launch Ink-based TUI mode
7
+ * ui Launch workspace TUI
8
+ * --tui Alias for tui (kept for compat)
8
9
  * --serve Start all configured channel bots
9
10
  * --telegram Start Telegram bot only
10
11
  * --discord Start Discord bot only
@@ -22,6 +23,84 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
23
 
23
24
  const args = process.argv.slice(2);
24
25
 
26
+ // ── ws sub-command ────────────────────────────────────────────────────────────
27
+ if (args[0] === "ws") {
28
+ const { handleWsCommand } = await import(
29
+ path.join(__dirname, "..", "lib", "commands", "ws.mjs")
30
+ );
31
+ await handleWsCommand(args);
32
+ process.exit(0);
33
+ }
34
+
35
+ // ── trust sub-command ─────────────────────────────────────────────────────────
36
+ if (args[0] === "trust") {
37
+ const { handleTrustCommand } = await import(
38
+ path.join(__dirname, "..", "lib", "commands", "trust.mjs")
39
+ );
40
+ await handleTrustCommand(args);
41
+ process.exit(0);
42
+ }
43
+
44
+ // ── where sub-command ─────────────────────────────────────────────────────────
45
+ if (args[0] === "where") {
46
+ const { cmdWhere } = await import(
47
+ path.join(__dirname, "..", "lib", "commands", "continuity.mjs")
48
+ );
49
+ await cmdWhere();
50
+ process.exit(0);
51
+ }
52
+
53
+ // ── handoff sub-command ───────────────────────────────────────────────────────
54
+ if (args[0] === "handoff") {
55
+ const { handleContinuityCommand } = await import(
56
+ path.join(__dirname, "..", "lib", "commands", "continuity.mjs")
57
+ );
58
+ await handleContinuityCommand(args);
59
+ process.exit(0);
60
+ }
61
+
62
+ // ── skill sub-command ─────────────────────────────────────────────────────────
63
+ if (args[0] === "skill") {
64
+ const { handleSkillCommand } = await import(
65
+ path.join(__dirname, "..", "lib", "commands", "skills-cmd.mjs")
66
+ );
67
+ await handleSkillCommand(args);
68
+ process.exit(0);
69
+ }
70
+
71
+ // ── teach sub-command ─────────────────────────────────────────────────────────
72
+ if (args[0] === "teach") {
73
+ const { cmdTeach } = await import(
74
+ path.join(__dirname, "..", "lib", "commands", "skills-cmd.mjs")
75
+ );
76
+ await cmdTeach(args[1]);
77
+ process.exit(0);
78
+ }
79
+
80
+ // ── improve sub-command ───────────────────────────────────────────────────────
81
+ 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);
88
+ process.exit(0);
89
+ }
90
+
91
+ // ── dry sub-command ───────────────────────────────────────────────────────────
92
+ if (args[0] === "dry") {
93
+ // Re-launch wispy with DRY_RUN env set, passing remaining args
94
+ const { spawn } = await import("node:child_process");
95
+ const remaining = args.slice(1);
96
+ const child = spawn(process.execPath, [process.argv[1], ...remaining], {
97
+ stdio: "inherit",
98
+ env: { ...process.env, WISPY_DRY_RUN: "1" },
99
+ });
100
+ child.on("exit", (code) => process.exit(code ?? 0));
101
+ await new Promise(() => {}); // keep alive until child exits
102
+ }
103
+
25
104
  // ── setup / init sub-command ──────────────────────────────────────────────────
26
105
  if (args[0] === "setup" || args[0] === "init") {
27
106
  const { OnboardingWizard } = await import(
@@ -1023,7 +1102,7 @@ if (serveMode || telegramMode || discordMode || slackMode) {
1023
1102
  const isInteractiveStart = !args.some(a =>
1024
1103
  ["--serve", "--telegram", "--discord", "--slack", "--server",
1025
1104
  "status", "setup", "init", "connect", "disconnect", "deploy",
1026
- "cron", "audit", "log", "server", "node", "channel", "sync"].includes(a)
1105
+ "cron", "audit", "log", "server", "node", "channel", "sync", "tui"].includes(a)
1027
1106
  );
1028
1107
 
1029
1108
  if (isInteractiveStart) {
@@ -1045,10 +1124,11 @@ if (isInteractiveStart) {
1045
1124
  }
1046
1125
 
1047
1126
  // ── TUI mode ──────────────────────────────────────────────────────────────────
1048
- const tuiMode = args.includes("--tui");
1127
+ // `wispy ui` is the primary way; `--tui` is kept as a hidden backwards-compat alias
1128
+ const tuiMode = args[0] === "tui" || args.includes("--tui");
1049
1129
 
1050
1130
  if (tuiMode) {
1051
- const newArgs = args.filter(a => a !== "--tui");
1131
+ const newArgs = args.filter(a => a !== "--tui" && a !== "tui");
1052
1132
  process.argv = [process.argv[0], process.argv[1], ...newArgs];
1053
1133
  const tuiScript = path.join(__dirname, "..", "lib", "wispy-tui.mjs");
1054
1134
  await import(tuiScript);
@@ -0,0 +1,280 @@
1
+ /**
2
+ * lib/commands/continuity.mjs — Local↔Cloud continuity commands
3
+ *
4
+ * wispy where show current mode: local/remote, provider, workstream
5
+ * wispy handoff cloud sync push + generate context summary + show connect URL
6
+ * wispy handoff local sync pull + show what was done remotely
7
+ */
8
+
9
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
10
+ import path from "node:path";
11
+ import os from "node:os";
12
+
13
+ const WISPY_DIR = path.join(os.homedir(), ".wispy");
14
+ const CONFIG_PATH = path.join(WISPY_DIR, "config.json");
15
+ const REMOTE_FILE = path.join(WISPY_DIR, "remote.json");
16
+ const SYNC_CONFIG = path.join(WISPY_DIR, "sync.json");
17
+ const MEMORY_DIR = path.join(WISPY_DIR, "memory");
18
+ const CONVERSATIONS_DIR = path.join(WISPY_DIR, "conversations");
19
+
20
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
21
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
22
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
23
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
24
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
25
+
26
+ async function readJsonOr(filePath, fallback = null) {
27
+ try { return JSON.parse(await readFile(filePath, "utf8")); } catch { return fallback; }
28
+ }
29
+
30
+ async function getActiveWorkstream() {
31
+ const envWs = process.env.WISPY_WORKSTREAM;
32
+ if (envWs) return envWs;
33
+ const cfg = await readJsonOr(CONFIG_PATH, {});
34
+ return cfg.workstream ?? "default";
35
+ }
36
+
37
+ async function getProviderLabel(cfg) {
38
+ const providerMap = {
39
+ google: "Google AI (gemini)",
40
+ anthropic: "Anthropic (Claude)",
41
+ openai: "OpenAI (GPT)",
42
+ openrouter: "OpenRouter",
43
+ groq: "Groq",
44
+ deepseek: "DeepSeek",
45
+ ollama: "Ollama (local)",
46
+ };
47
+ const model = cfg.model ?? "";
48
+ const provider = cfg.provider ?? "auto";
49
+ const label = providerMap[provider] ?? provider;
50
+ return model ? `${label} / ${model}` : label;
51
+ }
52
+
53
+ async function getMemoryCount() {
54
+ try {
55
+ const { readdir } = await import("node:fs/promises");
56
+ const files = await readdir(MEMORY_DIR);
57
+ return files.filter(f => f.endsWith(".md") || f.endsWith(".json")).length;
58
+ } catch { return 0; }
59
+ }
60
+
61
+ async function getSessionCount(workstream) {
62
+ try {
63
+ const convFile = path.join(CONVERSATIONS_DIR, `${workstream}.json`);
64
+ const conv = await readJsonOr(convFile, []);
65
+ return conv.filter(m => m.role === "user").length;
66
+ } catch { return 0; }
67
+ }
68
+
69
+ async function getLastSyncTime() {
70
+ const syncCfg = await readJsonOr(SYNC_CONFIG, null);
71
+ return syncCfg?.lastSync ?? null;
72
+ }
73
+
74
+ // ── Commands ─────────────────────────────────────────────────────────────────
75
+
76
+ export async function cmdWhere() {
77
+ const cfg = await readJsonOr(CONFIG_PATH, {});
78
+ const remote = await readJsonOr(REMOTE_FILE, null);
79
+
80
+ const mode = remote?.url ? "Remote" : "Local";
81
+ const modeColor = remote?.url ? yellow : green;
82
+
83
+ const workstream = await getActiveWorkstream();
84
+ const provider = await getProviderLabel(cfg);
85
+ const secLevel = cfg.securityLevel ?? "balanced";
86
+ const memCount = await getMemoryCount();
87
+ const sessionCount = await getSessionCount(workstream);
88
+
89
+ // Sync status
90
+ const lastSync = await getLastSyncTime();
91
+ const syncCfg = await readJsonOr(SYNC_CONFIG, null);
92
+ let syncStr = "not configured";
93
+ if (syncCfg?.remoteUrl) {
94
+ if (lastSync) {
95
+ const diffMs = Date.now() - new Date(lastSync).getTime();
96
+ const diffMin = Math.floor(diffMs / 60000);
97
+ const diffH = Math.floor(diffMin / 60);
98
+ const timeStr = diffMin < 60 ? `${diffMin}min ago` : `${diffH}h ago`;
99
+ syncStr = `${syncCfg.autoSync ? "auto" : "manual"} (last: ${timeStr})`;
100
+ } else {
101
+ syncStr = syncCfg.autoSync ? "auto (never synced)" : "configured";
102
+ }
103
+ }
104
+
105
+ const secIcons = { careful: "🔒", balanced: "⚖️", yolo: "🚀" };
106
+ const secIcon = secIcons[secLevel] ?? "";
107
+
108
+ console.log(`\n${bold("🌿 Wispy Status")}\n`);
109
+ console.log(` Mode: ${modeColor(mode)}${remote?.url ? ` ${dim(remote.url)}` : ""}`);
110
+ console.log(` Provider: ${provider}`);
111
+ console.log(` Workstream: ${cyan(workstream)}`);
112
+ console.log(` Security: ${secLevel} ${secIcon}`);
113
+ console.log(` Sync: ${dim(syncStr)}`);
114
+ console.log(` Memory: ${memCount} files`);
115
+ console.log(` Sessions: ${sessionCount} messages in current workstream`);
116
+
117
+ if (remote?.url) {
118
+ console.log(`\n ${dim("Remote:")} ${cyan(remote.url)}`);
119
+ if (remote.connectedAt) {
120
+ console.log(` ${dim("Connected:")} ${new Date(remote.connectedAt).toLocaleString()}`);
121
+ }
122
+ }
123
+
124
+ console.log("");
125
+ }
126
+
127
+ export async function cmdHandoffCloud() {
128
+ console.log(`\n${bold("☁️ Handoff to Cloud")}\n`);
129
+
130
+ // Step 1: Check sync config
131
+ const syncCfg = await readJsonOr(SYNC_CONFIG, null);
132
+ const remote = await readJsonOr(REMOTE_FILE, null);
133
+ const remoteUrl = syncCfg?.remoteUrl ?? remote?.url;
134
+ const token = syncCfg?.token ?? remote?.token;
135
+
136
+ if (!remoteUrl) {
137
+ console.log(yellow("⚠️ No remote configured."));
138
+ console.log(dim(" To sync, set up a remote server first:"));
139
+ console.log(dim(" wispy deploy vps user@host — deploy to VPS"));
140
+ console.log(dim(" wispy connect <url> --token <token> — connect to existing\n"));
141
+ return;
142
+ }
143
+
144
+ // Step 2: Sync push
145
+ process.stdout.write(` 1. Pushing data to ${cyan(remoteUrl)}... `);
146
+ try {
147
+ const { SyncManager } = await import("../../core/sync.mjs");
148
+ const mgr = new SyncManager({ remoteUrl, token, strategy: "newer-wins" });
149
+ const result = await mgr.push(remoteUrl, token, {});
150
+ console.log(green(`✓ pushed ${result.pushed} files`));
151
+ if (result.errors.length) {
152
+ console.log(yellow(` ⚠ ${result.errors.length} errors`));
153
+ }
154
+ } catch (err) {
155
+ console.log(yellow(`⚠ sync failed: ${err.message.slice(0, 60)}`));
156
+ console.log(dim(" Continuing with context summary..."));
157
+ }
158
+
159
+ // Step 3: Generate context summary
160
+ process.stdout.write(" 2. Summarizing current work state... ");
161
+ let summary = "Could not generate summary.";
162
+ try {
163
+ const workstream = await getActiveWorkstream();
164
+ const convFile = path.join(CONVERSATIONS_DIR, `${workstream}.json`);
165
+ const conv = await readJsonOr(convFile, []);
166
+ const recentMsgs = conv.slice(-20);
167
+ const userMsgs = recentMsgs.filter(m => m.role === "user").map(m => m.content).join("\n");
168
+ const assistantMsgs = recentMsgs.filter(m => m.role === "assistant").map(m => m.content).join("\n");
169
+
170
+ if (userMsgs || assistantMsgs) {
171
+ const cfg = await readJsonOr(CONFIG_PATH, {});
172
+ // Try to use AI for summary
173
+ try {
174
+ const { WispyEngine } = await import("../../core/engine.mjs");
175
+ const engine = new WispyEngine({ workstream });
176
+ const ok = await engine.init({ skipMcp: true });
177
+ if (ok) {
178
+ const result = await engine.processMessage(null, `Summarize what we were working on in 1-2 sentences. Recent messages:\n\nUser: ${userMsgs.slice(0, 500)}\n\nAssistant: ${assistantMsgs.slice(0, 500)}`, {
179
+ noSave: true,
180
+ });
181
+ summary = result?.content ?? summary;
182
+ engine.destroy();
183
+ }
184
+ } catch {
185
+ // Fallback: simple summary from last user message
186
+ const lastUserMsg = conv.filter(m => m.role === "user").pop();
187
+ if (lastUserMsg) {
188
+ summary = `Working on: ${lastUserMsg.content.slice(0, 120)}`;
189
+ }
190
+ }
191
+ } else {
192
+ summary = "No recent conversation activity.";
193
+ }
194
+ console.log(green("✓"));
195
+ } catch {
196
+ console.log(yellow("⚠ skipped"));
197
+ }
198
+
199
+ // Step 4: Show context + connect command
200
+ console.log(`\n ${bold("Context Summary:")}`);
201
+ console.log(` ${cyan(summary)}`);
202
+
203
+ console.log(`\n ${bold("Connect from cloud:")}`);
204
+ const connectCmd = token
205
+ ? `wispy connect ${remoteUrl} --token ${token.slice(0, 8)}...`
206
+ : `wispy connect ${remoteUrl}`;
207
+ console.log(` ${dim(connectCmd)}`);
208
+
209
+ console.log(dim(`\n Run 'wispy handoff local' to pull changes back.\n`));
210
+ }
211
+
212
+ export async function cmdHandoffLocal() {
213
+ console.log(`\n${bold("🏠 Handoff to Local")}\n`);
214
+
215
+ // Step 1: Check sync config
216
+ const syncCfg = await readJsonOr(SYNC_CONFIG, null);
217
+ const remote = await readJsonOr(REMOTE_FILE, null);
218
+ const remoteUrl = syncCfg?.remoteUrl ?? remote?.url;
219
+ const token = syncCfg?.token ?? remote?.token;
220
+
221
+ if (!remoteUrl) {
222
+ console.log(yellow("⚠️ No remote configured."));
223
+ console.log(dim(" Set up with: wispy connect <url> --token <token>\n"));
224
+ return;
225
+ }
226
+
227
+ // Step 2: Sync pull
228
+ process.stdout.write(` 1. Pulling from ${cyan(remoteUrl)}... `);
229
+ let pullResult = null;
230
+ try {
231
+ const { SyncManager } = await import("../../core/sync.mjs");
232
+ const mgr = new SyncManager({ remoteUrl, token, strategy: "newer-wins" });
233
+ pullResult = await mgr.pull(remoteUrl, token, {});
234
+ console.log(green(`✓ pulled ${pullResult.pulled} files`));
235
+ if (pullResult.conflicts) {
236
+ console.log(yellow(` ⚠ ${pullResult.conflicts} conflicts (saved as .conflict-* files)`));
237
+ }
238
+ } catch (err) {
239
+ console.log(yellow(`⚠ sync failed: ${err.message.slice(0, 60)}`));
240
+ }
241
+
242
+ // Step 3: Show what was done remotely
243
+ if (pullResult?.pulled > 0) {
244
+ console.log(`\n ${bold("Remote activity summary:")}`);
245
+ console.log(` ${green(pullResult.pulled)} files updated from remote`);
246
+ if (pullResult.skipped > 0) console.log(dim(` ${pullResult.skipped} files already up to date`));
247
+ }
248
+
249
+ // Step 4: Switch back to local mode if remote.json exists
250
+ const remoteExists = !!remote?.url;
251
+ if (remoteExists) {
252
+ const { unlink } = await import("node:fs/promises");
253
+ try {
254
+ await unlink(REMOTE_FILE);
255
+ console.log(green("\n ✓ Switched back to local mode"));
256
+ } catch {}
257
+ }
258
+
259
+ console.log(dim(`\n Local workstreams updated. Run 'wispy' to continue.\n`));
260
+ }
261
+
262
+ export async function handleContinuityCommand(args) {
263
+ const cmd = args[0];
264
+ const sub = args[1];
265
+
266
+ if (cmd === "where") return cmdWhere();
267
+
268
+ if (cmd === "handoff") {
269
+ if (sub === "cloud") return cmdHandoffCloud();
270
+ if (sub === "local") return cmdHandoffLocal();
271
+
272
+ console.log(`
273
+ ${bold("🔄 Handoff Commands")}
274
+
275
+ wispy where ${dim("show current mode: local/remote, provider, workstream")}
276
+ wispy handoff cloud ${dim("sync push + context summary + connect URL")}
277
+ wispy handoff local ${dim("sync pull + show remote activity + local mode")}
278
+ `);
279
+ }
280
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * lib/commands/skills-cmd.mjs — Skill CLI commands
3
+ *
4
+ * wispy skill list learned skills
5
+ * wispy skill run <name> run a skill
6
+ * wispy teach <name> create skill from last conversation
7
+ * wispy improve <name> "..." improve a skill with feedback
8
+ */
9
+
10
+ import { readFile, readdir } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+
14
+ const WISPY_DIR = path.join(os.homedir(), ".wispy");
15
+ const SKILLS_DIR = path.join(WISPY_DIR, "skills");
16
+ const CONVERSATIONS_DIR = path.join(WISPY_DIR, "conversations");
17
+
18
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
19
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
20
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
21
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
22
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
23
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
24
+
25
+ async function readJsonOr(filePath, fallback = null) {
26
+ try { return JSON.parse(await readFile(filePath, "utf8")); } catch { return fallback; }
27
+ }
28
+
29
+ async function getActiveWorkstream() {
30
+ const envWs = process.env.WISPY_WORKSTREAM;
31
+ if (envWs) return envWs;
32
+ const cfg = await readJsonOr(path.join(WISPY_DIR, "config.json"), {});
33
+ return cfg.workstream ?? "default";
34
+ }
35
+
36
+ async function listSkills() {
37
+ try {
38
+ const files = await readdir(SKILLS_DIR);
39
+ const skills = [];
40
+ for (const f of files.filter(f => f.endsWith(".json"))) {
41
+ const skill = await readJsonOr(path.join(SKILLS_DIR, f), null);
42
+ if (skill) skills.push(skill);
43
+ }
44
+ return skills.sort((a, b) => (b.timesUsed ?? 0) - (a.timesUsed ?? 0));
45
+ } catch { return []; }
46
+ }
47
+
48
+ // ── Commands ─────────────────────────────────────────────────────────────────
49
+
50
+ export async function cmdSkillList() {
51
+ const skills = await listSkills();
52
+
53
+ if (skills.length === 0) {
54
+ console.log(dim("\n🧠 No learned skills yet."));
55
+ console.log(dim(" Wispy auto-learns from complex multi-tool tasks."));
56
+ console.log(dim(" Or create one: wispy teach <name>\n"));
57
+ return;
58
+ }
59
+
60
+ console.log(`\n${bold(`🧠 Learned Skills (${skills.length})`)}\n`);
61
+
62
+ for (const s of skills) {
63
+ const used = s.timesUsed > 0 ? dim(` · used ${s.timesUsed}x`) : "";
64
+ const version = s.version > 1 ? dim(` · v${s.version}`) : "";
65
+ const tags = s.tags?.length > 0 ? dim(` [${s.tags.join(", ")}]`) : "";
66
+ const lastUsed = s.lastUsed ? dim(` · last used ${new Date(s.lastUsed).toLocaleDateString()}`) : "";
67
+
68
+ console.log(` ${green("/" + s.name.padEnd(25))} ${s.description ?? ""}${used}${version}${lastUsed}`);
69
+ if (tags) console.log(` ${" ".repeat(27)}${tags}`);
70
+ }
71
+
72
+ console.log(dim("\n Run: wispy skill run <name> or /<skill-name> in REPL"));
73
+ console.log(dim(" Create: wispy teach <name>"));
74
+ console.log(dim(" Improve: wispy improve <name> \"feedback\"\n"));
75
+ }
76
+
77
+ export async function cmdSkillRun(name) {
78
+ if (!name) {
79
+ console.log(yellow("Usage: wispy skill run <name>"));
80
+ return;
81
+ }
82
+
83
+ const { WispyEngine } = await import("../../core/engine.mjs");
84
+ const engine = new WispyEngine();
85
+ const ok = await engine.init({ skipMcp: false });
86
+ if (!ok) {
87
+ console.log(red("❌ No AI provider configured. Run: wispy setup"));
88
+ return;
89
+ }
90
+
91
+ const skill = await engine.skills.get(name);
92
+ if (!skill) {
93
+ console.log(red(`Skill '${name}' not found.`));
94
+ const allSkills = await listSkills();
95
+ if (allSkills.length > 0) {
96
+ console.log(dim(`Available: ${allSkills.map(s => s.name).join(", ")}`));
97
+ }
98
+ engine.destroy();
99
+ return;
100
+ }
101
+
102
+ console.log(dim(`\n🧠 Running skill: ${skill.name} (v${skill.version ?? 1})`));
103
+ console.log(dim(` ${skill.description ?? skill.prompt.slice(0, 80)}\n`));
104
+
105
+ try {
106
+ process.stdout.write(cyan("🌿 "));
107
+ const result = await engine.skills.execute(name, {}, null);
108
+ if (result?.content) {
109
+ console.log(result.content);
110
+ }
111
+ console.log("");
112
+ } catch (err) {
113
+ console.log(red(`\n✗ Skill error: ${err.message}`));
114
+ }
115
+
116
+ engine.destroy();
117
+ }
118
+
119
+ export async function cmdTeach(name) {
120
+ if (!name) {
121
+ console.log(yellow("Usage: wispy teach <name>"));
122
+ return;
123
+ }
124
+
125
+ const { WispyEngine } = await import("../../core/engine.mjs");
126
+ const engine = new WispyEngine();
127
+ const ok = await engine.init({ skipMcp: true });
128
+ if (!ok) {
129
+ console.log(red("❌ No AI provider configured."));
130
+ return;
131
+ }
132
+
133
+ // Load last conversation
134
+ const workstream = await getActiveWorkstream();
135
+ const convFile = path.join(CONVERSATIONS_DIR, `${workstream}.json`);
136
+ const conv = await readJsonOr(convFile, []);
137
+
138
+ if (conv.length === 0) {
139
+ console.log(yellow("No conversation found in current workstream to create skill from."));
140
+ engine.destroy();
141
+ return;
142
+ }
143
+
144
+ const userMessages = conv.filter(m => m.role === "user").slice(-5).map(m => m.content);
145
+ const taskSummary = userMessages.join(" / ").slice(0, 500);
146
+
147
+ console.log(`\n${bold("🧠 Teaching skill:")} ${cyan(name)}`);
148
+ console.log(dim(` Based on last ${userMessages.length} messages in '${workstream}'`));
149
+ process.stdout.write(dim(" Analyzing conversation..."));
150
+
151
+ try {
152
+ const skill = await engine.skills.create({
153
+ name,
154
+ description: `Created from '${workstream}' conversation`,
155
+ prompt: taskSummary,
156
+ tools: [],
157
+ tags: [workstream],
158
+ });
159
+
160
+ console.log(green(" ✓"));
161
+ console.log(green(`\n✅ Skill '${name}' created!`));
162
+ console.log(dim(` Prompt: ${skill.prompt.slice(0, 100)}...`));
163
+ console.log(dim(` Run: /${name} or wispy skill run ${name}\n`));
164
+ } catch (err) {
165
+ console.log(red(`\n✗ Failed: ${err.message}`));
166
+ }
167
+
168
+ engine.destroy();
169
+ }
170
+
171
+ export async function cmdImproveSkill(name, feedback) {
172
+ if (!name || !feedback) {
173
+ console.log(yellow('Usage: wispy improve <name> "feedback"'));
174
+ return;
175
+ }
176
+
177
+ const { WispyEngine } = await import("../../core/engine.mjs");
178
+ const engine = new WispyEngine();
179
+ const ok = await engine.init({ skipMcp: true });
180
+ if (!ok) {
181
+ console.log(red("❌ No AI provider configured."));
182
+ return;
183
+ }
184
+
185
+ const skill = await engine.skills.get(name);
186
+ if (!skill) {
187
+ console.log(red(`Skill '${name}' not found.`));
188
+ engine.destroy();
189
+ return;
190
+ }
191
+
192
+ process.stdout.write(`${dim("🧠 Improving skill '")}${name}${dim("'...")} `);
193
+ try {
194
+ const updated = await engine.skills.improve(name, feedback);
195
+ console.log(green(`✓ done (v${updated.version})`));
196
+ console.log(dim(` New prompt: ${updated.prompt.slice(0, 100)}...`));
197
+ } catch (err) {
198
+ console.log(red(`\n✗ ${err.message}`));
199
+ }
200
+
201
+ engine.destroy();
202
+ }
203
+
204
+ export async function handleSkillCommand(args) {
205
+ const sub = args[1];
206
+
207
+ if (!sub) return cmdSkillList();
208
+ if (sub === "run") return cmdSkillRun(args[2]);
209
+ if (sub === "list") return cmdSkillList();
210
+
211
+ console.log(`
212
+ ${bold("🧠 Skill Commands")}
213
+
214
+ wispy skill ${dim("list learned skills")}
215
+ wispy skill run <name> ${dim("run a skill")}
216
+ wispy teach <name> ${dim("create skill from last conversation")}
217
+ wispy improve <name> "..." ${dim("improve a skill with feedback")}
218
+ `);
219
+ }
@@ -0,0 +1,340 @@
1
+ /**
2
+ * lib/commands/trust.mjs — Trust / security level commands
3
+ *
4
+ * wispy trust show security level + recent approvals
5
+ * wispy trust level <careful|balanced|yolo> change security level
6
+ * wispy trust log show audit log
7
+ * wispy trust replay <id> replay session step by step
8
+ * wispy trust receipt <id> show execution receipt
9
+ */
10
+
11
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+
15
+ const WISPY_DIR = path.join(os.homedir(), ".wispy");
16
+ const CONFIG_PATH = path.join(WISPY_DIR, "config.json");
17
+ const PERMISSIONS_FILE = path.join(WISPY_DIR, "permissions.json");
18
+ const AUDIT_DIR = path.join(WISPY_DIR, "audit");
19
+
20
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
21
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
22
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
23
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
24
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
25
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
26
+
27
+ // Security presets
28
+ const SECURITY_PRESETS = {
29
+ careful: {
30
+ label: "careful 🔒",
31
+ color: green,
32
+ description: "Maximum oversight: approvals for most operations",
33
+ overrides: {
34
+ run_command: "approve",
35
+ write_file: "approve",
36
+ file_edit: "approve",
37
+ delete_file: "approve",
38
+ git: "approve",
39
+ spawn_subagent: "approve",
40
+ spawn_agent: "approve",
41
+ },
42
+ },
43
+ balanced: {
44
+ label: "balanced ⚖️",
45
+ color: yellow,
46
+ description: "Smart defaults: approve dangerous ops, notify writes",
47
+ overrides: {
48
+ run_command: "approve",
49
+ write_file: "notify",
50
+ file_edit: "notify",
51
+ delete_file: "approve",
52
+ git: "approve",
53
+ spawn_subagent: "notify",
54
+ spawn_agent: "notify",
55
+ },
56
+ },
57
+ yolo: {
58
+ label: "yolo 🚀",
59
+ color: red,
60
+ description: "Auto-approve everything (use with caution!)",
61
+ overrides: {
62
+ run_command: "auto",
63
+ write_file: "auto",
64
+ file_edit: "auto",
65
+ delete_file: "notify",
66
+ git: "auto",
67
+ spawn_subagent: "auto",
68
+ spawn_agent: "auto",
69
+ },
70
+ },
71
+ };
72
+
73
+ async function readJsonOr(filePath, fallback = null) {
74
+ try {
75
+ return JSON.parse(await readFile(filePath, "utf8"));
76
+ } catch {
77
+ return fallback;
78
+ }
79
+ }
80
+
81
+ async function getCurrentSecurityLevel() {
82
+ const cfg = await readJsonOr(CONFIG_PATH, {});
83
+ return cfg.securityLevel ?? "balanced";
84
+ }
85
+
86
+ async function getRecentApprovals(limit = 5) {
87
+ const approvals = [];
88
+ try {
89
+ const today = new Date().toISOString().slice(0, 10);
90
+ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
91
+
92
+ for (const dateStr of [today, yesterday]) {
93
+ const auditFile = path.join(AUDIT_DIR, `${dateStr}.jsonl`);
94
+ try {
95
+ const raw = await readFile(auditFile, "utf8");
96
+ const lines = raw.trim().split("\n").filter(Boolean);
97
+ for (const line of lines) {
98
+ try {
99
+ const evt = JSON.parse(line);
100
+ if (evt.type === "approval_granted" || evt.type === "approval_denied" || evt.type === "approval_requested") {
101
+ approvals.push(evt);
102
+ }
103
+ } catch {}
104
+ }
105
+ } catch {}
106
+ }
107
+ } catch {}
108
+
109
+ return approvals.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)).slice(0, limit);
110
+ }
111
+
112
+ // ── Commands ─────────────────────────────────────────────────────────────────
113
+
114
+ export async function cmdTrustShow() {
115
+ const level = await getCurrentSecurityLevel();
116
+ const preset = SECURITY_PRESETS[level] ?? SECURITY_PRESETS.balanced;
117
+
118
+ console.log(`\n${bold("🔐 Trust & Security")}\n`);
119
+ console.log(` Security level: ${preset.color(bold(preset.label))}`);
120
+ console.log(` ${dim(preset.description)}`);
121
+
122
+ // Load custom policy overrides
123
+ const permsData = await readJsonOr(PERMISSIONS_FILE, {});
124
+ const overrides = permsData.policies ?? {};
125
+ if (Object.keys(overrides).length > 0) {
126
+ console.log(`\n ${bold("Custom overrides:")} ${dim(`(${Object.keys(overrides).length} tools customized)`)}`);
127
+ for (const [tool, pol] of Object.entries(overrides).slice(0, 5)) {
128
+ const icon = pol === "approve" ? "🔐" : pol === "notify" ? "📋" : "✅";
129
+ console.log(` ${icon} ${tool}: ${pol}`);
130
+ }
131
+ if (Object.keys(overrides).length > 5) {
132
+ console.log(dim(` ... and ${Object.keys(overrides).length - 5} more`));
133
+ }
134
+ }
135
+
136
+ // Recent approvals
137
+ const approvals = await getRecentApprovals(5);
138
+ if (approvals.length > 0) {
139
+ console.log(`\n ${bold("Recent approvals:")}`);
140
+ for (const a of approvals) {
141
+ const ts = new Date(a.timestamp).toLocaleTimeString();
142
+ const icon = a.type === "approval_granted" ? green("✓") : a.type === "approval_denied" ? red("✗") : yellow("?");
143
+ const tool = a.tool ?? a.toolName ?? "unknown";
144
+ console.log(` ${icon} ${dim(ts)} ${tool}`);
145
+ }
146
+ } else {
147
+ console.log(dim("\n No recent approval activity."));
148
+ }
149
+
150
+ console.log(dim(`\n Change: wispy trust level <careful|balanced|yolo>\n`));
151
+ }
152
+
153
+ export async function cmdTrustLevel(level) {
154
+ if (!level) {
155
+ console.log(yellow("Usage: wispy trust level <careful|balanced|yolo>"));
156
+ return;
157
+ }
158
+
159
+ if (!SECURITY_PRESETS[level]) {
160
+ console.log(red(`Unknown level: ${level}. Use: careful, balanced, or yolo`));
161
+ return;
162
+ }
163
+
164
+ // Update config
165
+ const cfg = await readJsonOr(CONFIG_PATH, {});
166
+ cfg.securityLevel = level;
167
+ await mkdir(WISPY_DIR, { recursive: true });
168
+ await writeFile(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", "utf8");
169
+
170
+ // Update permissions.json with preset overrides
171
+ const permsData = await readJsonOr(PERMISSIONS_FILE, { policies: {} });
172
+ const preset = SECURITY_PRESETS[level];
173
+ permsData.policies = { ...permsData.policies, ...preset.overrides };
174
+ await writeFile(PERMISSIONS_FILE, JSON.stringify(permsData, null, 2) + "\n", "utf8");
175
+
176
+ const p = SECURITY_PRESETS[level];
177
+ console.log(`\n${green("✅")} Security level set to: ${p.color(bold(p.label))}`);
178
+ console.log(dim(` ${p.description}`));
179
+ console.log(dim(` ${Object.keys(p.overrides).length} tool policies updated.\n`));
180
+ }
181
+
182
+ export async function cmdTrustLog(extraArgs = []) {
183
+ // Delegate to audit command by running inline
184
+ const { AuditLog } = await import("../../core/audit.mjs");
185
+ const audit = new AuditLog(WISPY_DIR);
186
+
187
+ const filter = {};
188
+ const limitIdx = extraArgs.indexOf("--limit");
189
+ filter.limit = limitIdx !== -1 ? parseInt(extraArgs[limitIdx + 1]) : 20;
190
+ if (extraArgs.includes("--today")) filter.date = new Date().toISOString().slice(0, 10);
191
+
192
+ const events = await audit.search(filter);
193
+
194
+ if (events.length === 0) {
195
+ console.log(dim("No audit events found."));
196
+ } else {
197
+ console.log(`\n${bold("📋 Audit Log")} ${dim(`(${events.length} events)`)}\n`);
198
+ for (const evt of events) {
199
+ const ts = new Date(evt.timestamp).toLocaleTimeString();
200
+ const icons = {
201
+ tool_call: "🔧", tool_result: "✅", approval_requested: "⚠️ ",
202
+ approval_granted: "✅", approval_denied: "❌", message_sent: "🌿",
203
+ message_received: "👤", error: "🚨", subagent_spawned: "🤖",
204
+ subagent_completed: "🎉", cron_executed: "🕐",
205
+ };
206
+ const icon = icons[evt.type] ?? "•";
207
+ let detail = "";
208
+ if (evt.tool) detail += ` ${cyan(evt.tool)}`;
209
+ if (evt.content) detail += ` ${dim(evt.content.slice(0, 60))}`;
210
+ const sid = evt.sessionId ? dim(` [${evt.sessionId.slice(-8)}]`) : "";
211
+ console.log(` ${dim(ts)} ${icon} ${evt.type}${detail}${sid}`);
212
+ }
213
+ console.log("");
214
+ }
215
+ }
216
+
217
+ export async function cmdTrustReplay(sessionId) {
218
+ if (!sessionId) {
219
+ console.log(yellow("Usage: wispy trust replay <session-id>"));
220
+ return;
221
+ }
222
+
223
+ const { AuditLog } = await import("../../core/audit.mjs");
224
+ const audit = new AuditLog(WISPY_DIR);
225
+
226
+ const steps = await audit.getReplayTrace(sessionId);
227
+
228
+ if (steps.length === 0) {
229
+ console.log(red(`No events found for session: ${sessionId}`));
230
+ return;
231
+ }
232
+
233
+ console.log(`\n${bold("🎬 Session Replay")} ${dim(sessionId)}\n`);
234
+ console.log(dim(" Press Enter to advance each step...\n"));
235
+
236
+ const { createInterface } = await import("node:readline");
237
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
238
+
239
+ const wait = () => new Promise(r => rl.question("", r));
240
+
241
+ const icons = {
242
+ user_message: "👤", assistant_message: "🌿", tool_call: "🔧",
243
+ tool_result: "✅", approval_requested: "⚠️ ", approval_granted: "✅",
244
+ approval_denied: "❌", subagent_spawned: "🤖", subagent_completed: "🎉",
245
+ };
246
+
247
+ for (const step of steps) {
248
+ const ts = new Date(step.timestamp).toLocaleTimeString();
249
+ const icon = icons[step.type] ?? "•";
250
+ let detail = "";
251
+ if (step.content) detail = dim(step.content.slice(0, 100));
252
+ if (step.tool) detail = `${cyan(step.tool)} ${dim(JSON.stringify(step.args ?? {}).slice(0, 60))}`;
253
+
254
+ console.log(` ${bold(`Step ${step.step}`)} ${dim(ts)} ${icon} ${detail}`);
255
+
256
+ if (step.step < steps.length) {
257
+ await wait();
258
+ }
259
+ }
260
+
261
+ rl.close();
262
+ console.log(dim("\n Replay complete.\n"));
263
+ }
264
+
265
+ export async function cmdTrustReceipt(id) {
266
+ if (!id) {
267
+ console.log(yellow("Usage: wispy trust receipt <session-id>"));
268
+ return;
269
+ }
270
+
271
+ const { AuditLog } = await import("../../core/audit.mjs");
272
+ const audit = new AuditLog(WISPY_DIR);
273
+
274
+ const events = await audit.search({ sessionId: id, limit: 100 });
275
+
276
+ if (events.length === 0) {
277
+ console.log(red(`No events found for: ${id}`));
278
+ return;
279
+ }
280
+
281
+ // Build receipt
282
+ const toolCalls = events.filter(e => e.type === "tool_call");
283
+ const approvals = events.filter(e => e.type.startsWith("approval_"));
284
+ const firstEvent = events[events.length - 1];
285
+ const lastEvent = events[0];
286
+
287
+ const startTime = new Date(firstEvent?.timestamp);
288
+ const endTime = new Date(lastEvent?.timestamp);
289
+ const duration = Math.round((endTime - startTime) / 1000);
290
+
291
+ console.log(`\n${bold("🧾 Execution Receipt")}\n`);
292
+ console.log(` Session: ${cyan(id)}`);
293
+ console.log(` Started: ${startTime.toLocaleString()}`);
294
+ console.log(` Ended: ${endTime.toLocaleString()}`);
295
+ console.log(` Duration: ${duration}s`);
296
+ console.log(` Events: ${events.length}`);
297
+ console.log(` Tool calls: ${toolCalls.length}`);
298
+
299
+ if (approvals.length > 0) {
300
+ console.log(` Approvals: ${approvals.length}`);
301
+ for (const a of approvals) {
302
+ const icon = a.type === "approval_granted" ? green("✓") : a.type === "approval_denied" ? red("✗") : yellow("?");
303
+ console.log(` ${icon} ${a.tool ?? a.toolName ?? "?"} — ${a.type.replace("approval_", "")}`);
304
+ }
305
+ }
306
+
307
+ if (toolCalls.length > 0) {
308
+ console.log(`\n ${bold("Tools used:")}`);
309
+ const toolCounts = {};
310
+ for (const tc of toolCalls) {
311
+ const name = tc.tool ?? "?";
312
+ toolCounts[name] = (toolCounts[name] ?? 0) + 1;
313
+ }
314
+ for (const [tool, count] of Object.entries(toolCounts)) {
315
+ console.log(` ${cyan(tool)}: ${count}x`);
316
+ }
317
+ }
318
+
319
+ console.log("");
320
+ }
321
+
322
+ export async function handleTrustCommand(args) {
323
+ const sub = args[1];
324
+
325
+ if (!sub) return cmdTrustShow();
326
+ if (sub === "level") return cmdTrustLevel(args[2]);
327
+ if (sub === "log") return cmdTrustLog(args.slice(2));
328
+ if (sub === "replay") return cmdTrustReplay(args[2]);
329
+ if (sub === "receipt") return cmdTrustReceipt(args[2]);
330
+
331
+ console.log(`
332
+ ${bold("🔐 Trust Commands")}
333
+
334
+ wispy trust ${dim("show security level + recent approvals")}
335
+ wispy trust level <careful|balanced|yolo> ${dim("change security level")}
336
+ wispy trust log ${dim("show audit log")}
337
+ wispy trust replay <session-id> ${dim("replay session step by step")}
338
+ wispy trust receipt <session-id> ${dim("show execution receipt")}
339
+ `);
340
+ }
@@ -0,0 +1,422 @@
1
+ /**
2
+ * lib/commands/ws.mjs — Workstream CLI commands
3
+ *
4
+ * wispy ws list all workstreams with last activity
5
+ * wispy ws new <name> create new workstream
6
+ * wispy ws switch <name> switch active workstream
7
+ * wispy ws archive <name> archive workstream
8
+ * wispy ws status detailed status of all workstreams
9
+ * wispy ws search <query> search across all workstreams
10
+ * wispy ws delete <name> delete workstream
11
+ */
12
+
13
+ import { readFile, writeFile, mkdir, readdir, stat, rename, rm } from "node:fs/promises";
14
+ import { existsSync } from "node:fs";
15
+ import path from "node:path";
16
+ import os from "node:os";
17
+
18
+ const WISPY_DIR = path.join(os.homedir(), ".wispy");
19
+ const WORKSTREAMS_DIR = path.join(WISPY_DIR, "workstreams");
20
+ const CONVERSATIONS_DIR = path.join(WISPY_DIR, "conversations");
21
+ const MEMORY_DIR = path.join(WISPY_DIR, "memory");
22
+ const ARCHIVE_DIR = path.join(WISPY_DIR, "archive");
23
+ const CONFIG_PATH = path.join(WISPY_DIR, "config.json");
24
+
25
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
26
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
27
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
28
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
29
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
30
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
31
+
32
+ async function readJsonOr(filePath, fallback = null) {
33
+ try {
34
+ return JSON.parse(await readFile(filePath, "utf8"));
35
+ } catch {
36
+ return fallback;
37
+ }
38
+ }
39
+
40
+ async function getConfig() {
41
+ return readJsonOr(CONFIG_PATH, {});
42
+ }
43
+
44
+ async function saveConfig(cfg) {
45
+ await mkdir(WISPY_DIR, { recursive: true });
46
+ await writeFile(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", "utf8");
47
+ }
48
+
49
+ async function getActiveWorkstream() {
50
+ const envWs = process.env.WISPY_WORKSTREAM;
51
+ if (envWs) return envWs;
52
+ const cfg = await getConfig();
53
+ return cfg.workstream ?? "default";
54
+ }
55
+
56
+ /**
57
+ * Get all workstreams by scanning conversations/ dir (legacy) and workstreams/ dir
58
+ */
59
+ async function listAllWorkstreams() {
60
+ const found = new Set(["default"]);
61
+ const results = [];
62
+
63
+ // Scan conversations/
64
+ try {
65
+ const files = await readdir(CONVERSATIONS_DIR);
66
+ for (const f of files) {
67
+ if (f.endsWith(".json")) found.add(f.replace(".json", ""));
68
+ }
69
+ } catch {}
70
+
71
+ // Scan workstreams/
72
+ try {
73
+ const dirs = await readdir(WORKSTREAMS_DIR);
74
+ for (const d of dirs) {
75
+ found.add(d);
76
+ }
77
+ } catch {}
78
+
79
+ const active = await getActiveWorkstream();
80
+
81
+ for (const name of found) {
82
+ const wsDir = path.join(WORKSTREAMS_DIR, name);
83
+ const convFile = path.join(CONVERSATIONS_DIR, `${name}.json`);
84
+ const workMd = path.join(wsDir, "work.md");
85
+
86
+ let lastActive = null;
87
+ let sessionCount = 0;
88
+
89
+ // Check conversation file
90
+ try {
91
+ const s = await stat(convFile);
92
+ lastActive = s.mtime;
93
+ const conv = await readJsonOr(convFile, []);
94
+ sessionCount = conv.filter(m => m.role === "user").length;
95
+ } catch {}
96
+
97
+ // Check workstream dir
98
+ try {
99
+ const s = await stat(wsDir);
100
+ if (!lastActive || s.mtime > lastActive) lastActive = s.mtime;
101
+ } catch {}
102
+
103
+ results.push({
104
+ name,
105
+ isActive: name === active,
106
+ lastActive,
107
+ sessionCount,
108
+ hasWorkMd: existsSync(workMd),
109
+ wsDir,
110
+ convFile,
111
+ });
112
+ }
113
+
114
+ // Sort: active first, then by last activity
115
+ results.sort((a, b) => {
116
+ if (a.isActive && !b.isActive) return -1;
117
+ if (!a.isActive && b.isActive) return 1;
118
+ const ta = a.lastActive ? a.lastActive.getTime() : 0;
119
+ const tb = b.lastActive ? b.lastActive.getTime() : 0;
120
+ return tb - ta;
121
+ });
122
+
123
+ return results;
124
+ }
125
+
126
+ function formatRelative(date) {
127
+ if (!date) return dim("never");
128
+ const diffMs = Date.now() - new Date(date).getTime();
129
+ const diffMin = Math.floor(diffMs / 60000);
130
+ const diffH = Math.floor(diffMin / 60);
131
+ const diffD = Math.floor(diffH / 24);
132
+ if (diffMin < 1) return green("just now");
133
+ if (diffMin < 60) return `${diffMin}min ago`;
134
+ if (diffH < 24) return `${diffH}h ago`;
135
+ if (diffD < 7) return `${diffD}d ago`;
136
+ return new Date(date).toLocaleDateString();
137
+ }
138
+
139
+ // ── Commands ─────────────────────────────────────────────────────────────────
140
+
141
+ export async function cmdWsList() {
142
+ const workstreams = await listAllWorkstreams();
143
+ if (workstreams.length === 0) {
144
+ console.log(dim("No workstreams yet. Create one: wispy ws new <name>"));
145
+ return;
146
+ }
147
+
148
+ console.log(`\n${bold("🌿 Workstreams")}\n`);
149
+ for (const ws of workstreams) {
150
+ const marker = ws.isActive ? green("●") : "○";
151
+ const name = ws.isActive ? bold(green(ws.name)) : ws.name;
152
+ const msgs = ws.sessionCount > 0 ? dim(` · ${ws.sessionCount} msgs`) : "";
153
+ const last = ` ${dim(formatRelative(ws.lastActive))}`;
154
+ const plan = ws.hasWorkMd ? cyan(" 📋") : "";
155
+ console.log(` ${marker} ${name.padEnd(25)}${last}${msgs}${plan}`);
156
+ }
157
+
158
+ const active = await getActiveWorkstream();
159
+ console.log(dim(`\n Active: ${active} Switch: wispy ws <name> Create: wispy ws new <name>\n`));
160
+ }
161
+
162
+ export async function cmdWsNew(name) {
163
+ if (!name) {
164
+ console.log(yellow("Usage: wispy ws new <name>"));
165
+ return;
166
+ }
167
+
168
+ const wsDir = path.join(WORKSTREAMS_DIR, name);
169
+ await mkdir(wsDir, { recursive: true });
170
+
171
+ const workMd = path.join(wsDir, "work.md");
172
+ const workMdContent = `# ${name} Workstream
173
+
174
+ ## Current Work
175
+
176
+ > Update this as you progress.
177
+
178
+ ## Goals
179
+
180
+
181
+ ## Context
182
+
183
+
184
+ ## Next Steps
185
+
186
+
187
+ ## Notes
188
+
189
+ `;
190
+ await writeFile(workMd, workMdContent, "utf8");
191
+
192
+ // Also create conversations entry
193
+ await mkdir(CONVERSATIONS_DIR, { recursive: true });
194
+ const convFile = path.join(CONVERSATIONS_DIR, `${name}.json`);
195
+ if (!existsSync(convFile)) {
196
+ await writeFile(convFile, "[]", "utf8");
197
+ }
198
+
199
+ console.log(green(`✅ Created workstream: ${name}`));
200
+ console.log(dim(` Dir: ${wsDir}`));
201
+ console.log(dim(` Run: wispy ws switch ${name} — to activate`));
202
+ }
203
+
204
+ export async function cmdWsSwitch(name) {
205
+ if (!name) {
206
+ console.log(yellow("Usage: wispy ws switch <name>"));
207
+ return;
208
+ }
209
+
210
+ const cfg = await getConfig();
211
+ cfg.workstream = name;
212
+ await saveConfig(cfg);
213
+
214
+ console.log(green(`✅ Active workstream: ${bold(name)}`));
215
+ console.log(dim(` Start a session: wispy (or set WISPY_WORKSTREAM=${name})`));
216
+ }
217
+
218
+ export async function cmdWsArchive(name) {
219
+ if (!name) {
220
+ console.log(yellow("Usage: wispy ws archive <name>"));
221
+ return;
222
+ }
223
+
224
+ const archiveWsDir = path.join(ARCHIVE_DIR, name);
225
+ await mkdir(archiveWsDir, { recursive: true });
226
+
227
+ let moved = 0;
228
+
229
+ // Move workstream dir
230
+ const wsDir = path.join(WORKSTREAMS_DIR, name);
231
+ if (existsSync(wsDir)) {
232
+ await rename(wsDir, path.join(archiveWsDir, "workstream")).catch(() => {});
233
+ moved++;
234
+ }
235
+
236
+ // Move conversation
237
+ const convFile = path.join(CONVERSATIONS_DIR, `${name}.json`);
238
+ if (existsSync(convFile)) {
239
+ await rename(convFile, path.join(archiveWsDir, "conversation.json")).catch(() => {});
240
+ moved++;
241
+ }
242
+
243
+ if (moved === 0) {
244
+ console.log(yellow(`Workstream '${name}' not found or already archived.`));
245
+ return;
246
+ }
247
+
248
+ console.log(green(`📦 Archived workstream: ${name}`));
249
+ console.log(dim(` Archive: ${archiveWsDir}`));
250
+ }
251
+
252
+ export async function cmdWsStatus() {
253
+ const workstreams = await listAllWorkstreams();
254
+
255
+ console.log(`\n${bold("🌿 Workstream Status")}\n`);
256
+
257
+ for (const ws of workstreams) {
258
+ const marker = ws.isActive ? green("●") : "○";
259
+ const name = ws.isActive ? bold(green(ws.name)) : bold(ws.name);
260
+ console.log(`${marker} ${name}`);
261
+
262
+ // Session count
263
+ console.log(` Sessions: ${ws.sessionCount} messages`);
264
+
265
+ // Last active
266
+ console.log(` Last active: ${formatRelative(ws.lastActive)}`);
267
+
268
+ // Memory files
269
+ const wsMemDir = path.join(ws.wsDir, "memory");
270
+ try {
271
+ const memFiles = await readdir(wsMemDir);
272
+ console.log(` Memory: ${memFiles.length} files`);
273
+ } catch {
274
+ const globalMemFiles = await readdir(MEMORY_DIR).catch(() => []);
275
+ if (ws.isActive) console.log(` Memory: ${globalMemFiles.length} files (global)`);
276
+ }
277
+
278
+ // Work.md preview
279
+ if (ws.hasWorkMd) {
280
+ try {
281
+ const workMdContent = await readFile(path.join(ws.wsDir, "work.md"), "utf8");
282
+ const lines = workMdContent.split("\n").filter(l => l.trim() && !l.startsWith("#")).slice(0, 2);
283
+ if (lines.length > 0) {
284
+ console.log(dim(` Work: ${lines[0].replace(/^[>*\-] /, "").slice(0, 60)}`));
285
+ }
286
+ } catch {}
287
+ }
288
+
289
+ console.log("");
290
+ }
291
+ }
292
+
293
+ export async function cmdWsSearch(query) {
294
+ if (!query) {
295
+ console.log(yellow("Usage: wispy ws search <query>"));
296
+ return;
297
+ }
298
+
299
+ const workstreams = await listAllWorkstreams();
300
+ const lowerQuery = query.toLowerCase();
301
+
302
+ console.log(`\n${bold("🔍 Searching workstreams for:")} ${cyan(query)}\n`);
303
+ let totalMatches = 0;
304
+
305
+ for (const ws of workstreams) {
306
+ const matches = [];
307
+
308
+ // Search conversation
309
+ try {
310
+ const conv = await readJsonOr(ws.convFile, []);
311
+ const convMatches = conv.filter(m =>
312
+ (m.role === "user" || m.role === "assistant") &&
313
+ m.content?.toLowerCase().includes(lowerQuery)
314
+ );
315
+ for (const m of convMatches.slice(-3)) {
316
+ const preview = m.content.replace(/\n/g, " ").slice(0, 80);
317
+ matches.push(` ${m.role === "user" ? "👤" : "🌿"} ${dim(preview + (m.content.length > 80 ? "..." : ""))}`);
318
+ }
319
+ } catch {}
320
+
321
+ // Search work.md
322
+ if (ws.hasWorkMd) {
323
+ try {
324
+ const content = await readFile(path.join(ws.wsDir, "work.md"), "utf8");
325
+ if (content.toLowerCase().includes(lowerQuery)) {
326
+ const lines = content.split("\n").filter(l => l.toLowerCase().includes(lowerQuery)).slice(0, 2);
327
+ for (const l of lines) {
328
+ matches.push(` 📋 ${dim(l.trim().slice(0, 80))}`);
329
+ }
330
+ }
331
+ } catch {}
332
+ }
333
+
334
+ if (matches.length > 0) {
335
+ const name = ws.isActive ? bold(green(ws.name)) : bold(ws.name);
336
+ console.log(`${name} (${matches.length} match${matches.length === 1 ? "" : "es"}):`);
337
+ matches.forEach(m => console.log(m));
338
+ console.log("");
339
+ totalMatches += matches.length;
340
+ }
341
+ }
342
+
343
+ if (totalMatches === 0) {
344
+ console.log(dim(`No matches found for "${query}"`));
345
+ }
346
+ }
347
+
348
+ export async function cmdWsDelete(name) {
349
+ if (!name) {
350
+ console.log(yellow("Usage: wispy ws delete <name>"));
351
+ return;
352
+ }
353
+
354
+ if (name === "default") {
355
+ console.log(red("Cannot delete the default workstream."));
356
+ return;
357
+ }
358
+
359
+ const wsDir = path.join(WORKSTREAMS_DIR, name);
360
+ const convFile = path.join(CONVERSATIONS_DIR, `${name}.json`);
361
+
362
+ let deleted = 0;
363
+
364
+ if (existsSync(wsDir)) {
365
+ await rm(wsDir, { recursive: true, force: true });
366
+ deleted++;
367
+ }
368
+
369
+ if (existsSync(convFile)) {
370
+ const { unlink } = await import("node:fs/promises");
371
+ await unlink(convFile).catch(() => {});
372
+ deleted++;
373
+ }
374
+
375
+ if (deleted === 0) {
376
+ console.log(red(`Workstream '${name}' not found.`));
377
+ return;
378
+ }
379
+
380
+ // If it was active, reset to default
381
+ const cfg = await getConfig();
382
+ if (cfg.workstream === name) {
383
+ cfg.workstream = "default";
384
+ await saveConfig(cfg);
385
+ console.log(dim(" Reset active workstream to: default"));
386
+ }
387
+
388
+ console.log(green(`🗑️ Deleted workstream: ${name}`));
389
+ }
390
+
391
+ export async function handleWsCommand(args) {
392
+ const sub = args[1];
393
+
394
+ if (!sub) {
395
+ return cmdWsList();
396
+ }
397
+
398
+ if (sub === "new") return cmdWsNew(args[2]);
399
+ if (sub === "switch") return cmdWsSwitch(args[2]);
400
+ if (sub === "archive") return cmdWsArchive(args[2]);
401
+ if (sub === "status") return cmdWsStatus();
402
+ if (sub === "search") return cmdWsSearch(args.slice(2).join(" "));
403
+ if (sub === "delete" || sub === "rm") return cmdWsDelete(args[2]);
404
+
405
+ // If sub is not a keyword, treat it as a shortcut for ws switch
406
+ if (!["new", "switch", "archive", "status", "search", "delete", "rm", "--help", "-h"].includes(sub)) {
407
+ return cmdWsSwitch(sub);
408
+ }
409
+
410
+ console.log(`
411
+ ${bold("🌿 Workstream Commands")}
412
+
413
+ wispy ws ${dim("list all workstreams")}
414
+ wispy ws new <name> ${dim("create new workstream")}
415
+ wispy ws switch <name> ${dim("switch active workstream")}
416
+ wispy ws <name> ${dim("shortcut for switch")}
417
+ wispy ws archive <name> ${dim("archive workstream")}
418
+ wispy ws status ${dim("detailed status")}
419
+ wispy ws search <query> ${dim("search across all workstreams")}
420
+ wispy ws delete <name> ${dim("delete workstream")}
421
+ `);
422
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",