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.
- package/bin/wispy.mjs +172 -0
- package/core/config.mjs +34 -0
- package/core/engine.mjs +107 -12
- package/core/harness.mjs +222 -1
- package/core/index.mjs +2 -1
- package/core/providers.mjs +30 -0
- package/core/session.mjs +103 -0
- package/core/tools.mjs +204 -1
- package/lib/commands/review.mjs +272 -0
- package/lib/commands/trust.mjs +57 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/commands/trust.mjs
CHANGED
|
@@ -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