wispy-cli 2.7.11 → 2.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * lib/commands/review.mjs — Code review mode for Wispy
3
+ *
4
+ * wispy review Review uncommitted changes
5
+ * wispy review --base <branch> Review against base branch
6
+ * wispy review --commit <sha> Review specific commit
7
+ * wispy review --title <title> Add context title
8
+ * wispy review --json Output as JSON
9
+ */
10
+
11
+ import { execSync } from "node:child_process";
12
+ import path from "node:path";
13
+ import { WispyEngine } from "../../core/engine.mjs";
14
+
15
+ const CODE_REVIEW_SYSTEM_PROMPT = `You are a senior code reviewer. Analyze the following diff and provide:
16
+ 1. A brief summary of the changes
17
+ 2. Issues found, categorized by severity (critical, warning, info)
18
+ 3. Specific line-level comments with suggestions
19
+ 4. An overall assessment (approve, request-changes, comment)
20
+
21
+ Be constructive and specific. Reference line numbers when possible.
22
+
23
+ Format your response as follows:
24
+ ## Summary
25
+ [Brief summary of what changed]
26
+
27
+ ## Issues Found
28
+ ### Critical
29
+ [List critical issues, or "None" if clean]
30
+
31
+ ### Warnings
32
+ [List warnings, or "None"]
33
+
34
+ ### Info
35
+ [List informational notes, or "None"]
36
+
37
+ ## File Comments
38
+ [File-by-file comments with line references]
39
+
40
+ ## Assessment
41
+ **Verdict:** [approve | request-changes | comment]
42
+ [Brief overall assessment]`;
43
+
44
+ /**
45
+ * Get git diff based on options.
46
+ */
47
+ function getDiff(options = {}) {
48
+ const { base, commit, staged, cwd = process.cwd() } = options;
49
+ const execOpts = { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" };
50
+
51
+ let diff = "";
52
+
53
+ if (commit) {
54
+ // Review a specific commit
55
+ try {
56
+ diff = execSync(`git show ${commit}`, execOpts);
57
+ } catch (err) {
58
+ throw new Error(`Failed to get commit ${commit}: ${err.stderr?.slice(0, 200) ?? err.message}`);
59
+ }
60
+ } else if (base) {
61
+ // Review changes since base branch
62
+ try {
63
+ diff = execSync(`git diff ${base}...HEAD`, execOpts);
64
+ } catch (err) {
65
+ throw new Error(`Failed to diff against ${base}: ${err.stderr?.slice(0, 200) ?? err.message}`);
66
+ }
67
+ } else {
68
+ // Default: all uncommitted changes (staged + unstaged + untracked summary)
69
+ try {
70
+ const stageDiff = execSync("git diff --cached", execOpts);
71
+ const unstaged = execSync("git diff", execOpts);
72
+ diff = [stageDiff, unstaged].filter(Boolean).join("\n");
73
+
74
+ // Also include untracked file names if any
75
+ try {
76
+ const untracked = execSync("git ls-files --others --exclude-standard", execOpts).trim();
77
+ if (untracked) {
78
+ diff += `\n\n# Untracked files:\n${untracked.split("\n").map(f => `# + ${f}`).join("\n")}`;
79
+ }
80
+ } catch {}
81
+ } catch (err) {
82
+ throw new Error(`Failed to get git diff: ${err.stderr?.slice(0, 200) ?? err.message}`);
83
+ }
84
+ }
85
+
86
+ return diff.trim();
87
+ }
88
+
89
+ /**
90
+ * Get some context about the repo.
91
+ */
92
+ function getRepoContext(cwd = process.cwd()) {
93
+ const execOpts = { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" };
94
+ const context = {};
95
+
96
+ try {
97
+ context.branch = execSync("git rev-parse --abbrev-ref HEAD", execOpts).trim();
98
+ } catch {}
99
+
100
+ try {
101
+ context.lastCommit = execSync("git log -1 --pretty=%B", execOpts).trim().slice(0, 200);
102
+ } catch {}
103
+
104
+ try {
105
+ const stat = execSync("git diff --stat", execOpts).trim();
106
+ context.stat = stat.slice(0, 500);
107
+ } catch {}
108
+
109
+ return context;
110
+ }
111
+
112
+ /**
113
+ * Parse the AI review response into structured JSON.
114
+ */
115
+ function parseReviewResponse(text) {
116
+ const result = {
117
+ summary: "",
118
+ issues: { critical: [], warning: [], info: [] },
119
+ fileComments: [],
120
+ assessment: { verdict: "comment", explanation: "" },
121
+ raw: text,
122
+ };
123
+
124
+ // Extract summary
125
+ const summaryMatch = text.match(/## Summary\n([\s\S]*?)(?=##|$)/);
126
+ if (summaryMatch) result.summary = summaryMatch[1].trim();
127
+
128
+ // Extract issues
129
+ const criticalMatch = text.match(/### Critical\n([\s\S]*?)(?=###|##|$)/);
130
+ if (criticalMatch) {
131
+ const content = criticalMatch[1].trim();
132
+ if (content && content.toLowerCase() !== "none") {
133
+ result.issues.critical = content.split("\n").filter(l => l.trim() && l.trim() !== "-").map(l => l.replace(/^[-*•]\s*/, "").trim());
134
+ }
135
+ }
136
+
137
+ const warningsMatch = text.match(/### Warning[s]?\n([\s\S]*?)(?=###|##|$)/);
138
+ if (warningsMatch) {
139
+ const content = warningsMatch[1].trim();
140
+ if (content && content.toLowerCase() !== "none") {
141
+ result.issues.warning = content.split("\n").filter(l => l.trim() && l.trim() !== "-").map(l => l.replace(/^[-*•]\s*/, "").trim());
142
+ }
143
+ }
144
+
145
+ const infoMatch = text.match(/### Info\n([\s\S]*?)(?=###|##|$)/);
146
+ if (infoMatch) {
147
+ const content = infoMatch[1].trim();
148
+ if (content && content.toLowerCase() !== "none") {
149
+ result.issues.info = content.split("\n").filter(l => l.trim() && l.trim() !== "-").map(l => l.replace(/^[-*•]\s*/, "").trim());
150
+ }
151
+ }
152
+
153
+ // Extract assessment
154
+ const assessmentMatch = text.match(/## Assessment\n([\s\S]*?)(?=##|$)/);
155
+ if (assessmentMatch) {
156
+ const assessText = assessmentMatch[1].trim();
157
+ const verdictMatch = assessText.match(/\*\*Verdict:\*\*\s*(approve|request-changes|comment)/i);
158
+ if (verdictMatch) result.assessment.verdict = verdictMatch[1].toLowerCase();
159
+ result.assessment.explanation = assessText.replace(/\*\*Verdict:\*\*[^\n]*\n?/, "").trim();
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Main review handler.
167
+ */
168
+ export async function handleReviewCommand(args = []) {
169
+ // Parse args
170
+ const options = {
171
+ base: null,
172
+ commit: null,
173
+ title: null,
174
+ json: false,
175
+ cwd: process.cwd(),
176
+ };
177
+
178
+ for (let i = 0; i < args.length; i++) {
179
+ if (args[i] === "--base" && args[i + 1]) { options.base = args[++i]; }
180
+ else if (args[i] === "--commit" && args[i + 1]) { options.commit = args[++i]; }
181
+ else if (args[i] === "--title" && args[i + 1]) { options.title = args[++i]; }
182
+ else if (args[i] === "--json") { options.json = true; }
183
+ }
184
+
185
+ // Check git
186
+ try {
187
+ execSync("git rev-parse --is-inside-work-tree", {
188
+ cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"],
189
+ });
190
+ } catch {
191
+ console.error("❌ Not inside a git repository.");
192
+ process.exit(1);
193
+ }
194
+
195
+ // Get the diff
196
+ let diff;
197
+ try {
198
+ diff = getDiff(options);
199
+ } catch (err) {
200
+ console.error(`❌ ${err.message}`);
201
+ process.exit(1);
202
+ }
203
+
204
+ if (!diff) {
205
+ console.log("✅ Nothing to review — no changes found.");
206
+ process.exit(0);
207
+ }
208
+
209
+ const context = getRepoContext(options.cwd);
210
+
211
+ // Build review prompt
212
+ let prompt = "";
213
+ if (options.title) prompt += `# ${options.title}\n\n`;
214
+ if (context.branch) prompt += `**Branch:** \`${context.branch}\`\n`;
215
+ if (options.base) prompt += `**Review type:** Changes since \`${options.base}\`\n`;
216
+ if (options.commit) prompt += `**Commit:** \`${options.commit}\`\n`;
217
+ if (context.stat) prompt += `\n**Stats:**\n\`\`\`\n${context.stat}\n\`\`\`\n`;
218
+ prompt += `\n**Diff:**\n\`\`\`diff\n${diff.slice(0, 50_000)}\n\`\`\``;
219
+
220
+ if (diff.length > 50_000) {
221
+ prompt += `\n\n*(diff truncated — ${diff.length} chars total)*`;
222
+ }
223
+
224
+ // Show what we're reviewing
225
+ if (!options.json) {
226
+ if (options.commit) {
227
+ console.log(`\n🔍 Reviewing commit ${options.commit}...`);
228
+ } else if (options.base) {
229
+ console.log(`\n🔍 Reviewing changes since ${options.base}...`);
230
+ } else {
231
+ console.log("\n🔍 Reviewing uncommitted changes...");
232
+ }
233
+ if (context.stat) {
234
+ console.log(`\n${context.stat}\n`);
235
+ }
236
+ process.stdout.write("🌿 ");
237
+ }
238
+
239
+ // Send to AI
240
+ let reviewText = "";
241
+ try {
242
+ const engine = new WispyEngine();
243
+ const initResult = await engine.init({ skipMcp: true });
244
+ if (!initResult) {
245
+ console.error("❌ No AI provider configured. Set an API key to use code review.");
246
+ process.exit(1);
247
+ }
248
+
249
+ const response = await engine.processMessage(null, prompt, {
250
+ systemPrompt: CODE_REVIEW_SYSTEM_PROMPT,
251
+ onChunk: options.json ? null : (chunk) => process.stdout.write(chunk),
252
+ noSave: true,
253
+ skipSkillCapture: true,
254
+ skipUserModel: true,
255
+ });
256
+
257
+ reviewText = response.content;
258
+ try { engine.destroy(); } catch {}
259
+ } catch (err) {
260
+ console.error(`❌ Review failed: ${err.message}`);
261
+ process.exit(1);
262
+ }
263
+
264
+ if (!options.json) {
265
+ console.log("\n");
266
+ return;
267
+ }
268
+
269
+ // JSON output
270
+ const structured = parseReviewResponse(reviewText);
271
+ console.log(JSON.stringify(structured, null, 2));
272
+ }
@@ -319,6 +319,58 @@ export async function cmdTrustReceipt(id) {
319
319
  console.log("");
320
320
  }
321
321
 
322
+ export async function cmdTrustApprovals(subArgs = []) {
323
+ const { globalAllowlist } = await import("../../core/harness.mjs");
324
+ const action = subArgs[0];
325
+
326
+ if (!action || action === "list") {
327
+ const list = await globalAllowlist.getAll();
328
+ console.log(`\n${bold("🔐 Approval Allowlist")} ${dim("(auto-approved patterns)")}\n`);
329
+ let empty = true;
330
+ for (const [tool, patterns] of Object.entries(list)) {
331
+ if (patterns.length === 0) continue;
332
+ empty = false;
333
+ console.log(` ${cyan(tool)}:`);
334
+ for (const p of patterns) {
335
+ console.log(` ${dim("•")} ${p}`);
336
+ }
337
+ }
338
+ if (empty) {
339
+ console.log(dim(" No patterns configured."));
340
+ }
341
+ console.log(dim("\n Manage: wispy trust approvals add <tool> <pattern>"));
342
+ console.log(dim(" wispy trust approvals clear"));
343
+ console.log(dim(" wispy trust approvals reset\n"));
344
+ return;
345
+ }
346
+
347
+ if (action === "add") {
348
+ const tool = subArgs[1];
349
+ const pattern = subArgs[2];
350
+ if (!tool || !pattern) {
351
+ console.log(yellow("Usage: wispy trust approvals add <tool> <pattern>"));
352
+ return;
353
+ }
354
+ await globalAllowlist.add(tool, pattern);
355
+ console.log(`${green("✅")} Added pattern for ${cyan(tool)}: ${pattern}`);
356
+ return;
357
+ }
358
+
359
+ if (action === "clear") {
360
+ await globalAllowlist.clear();
361
+ console.log(green("✅ Allowlist cleared."));
362
+ return;
363
+ }
364
+
365
+ if (action === "reset") {
366
+ await globalAllowlist.reset();
367
+ console.log(green("✅ Allowlist reset to defaults."));
368
+ return;
369
+ }
370
+
371
+ console.log(yellow(`Unknown approvals action: ${action}. Use: list, add, clear, reset`));
372
+ }
373
+
322
374
  export async function handleTrustCommand(args) {
323
375
  const sub = args[1];
324
376
 
@@ -405,6 +457,7 @@ export async function handleTrustCommand(args) {
405
457
  if (sub === "log") return cmdTrustLog(args.slice(2));
406
458
  if (sub === "replay") return cmdTrustReplay(args[2]);
407
459
  if (sub === "receipt") return cmdTrustReceipt(args[2]);
460
+ if (sub === "approvals") return cmdTrustApprovals(args.slice(2));
408
461
 
409
462
  console.log(`
410
463
  ${bold("🔐 Trust Commands")}
@@ -414,5 +467,9 @@ ${bold("🔐 Trust Commands")}
414
467
  wispy trust log ${dim("show audit log")}
415
468
  wispy trust replay <session-id> ${dim("replay session step by step")}
416
469
  wispy trust receipt <session-id> ${dim("show execution receipt")}
470
+ wispy trust approvals ${dim("list approval allowlist")}
471
+ wispy trust approvals add <tool> <pat> ${dim("add an allowlist pattern")}
472
+ wispy trust approvals clear ${dim("clear all allowlist patterns")}
473
+ wispy trust approvals reset ${dim("reset allowlist to defaults")}
417
474
  `);
418
475
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.11",
3
+ "version": "2.7.13",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",