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
package/core/harness.mjs
CHANGED
|
@@ -13,12 +13,145 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { EventEmitter } from "node:events";
|
|
16
|
-
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
|
|
20
20
|
import { EVENT_TYPES } from "./audit.mjs";
|
|
21
21
|
|
|
22
|
+
// ── Approval gate constants ────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// Tools that require approval depending on security mode
|
|
25
|
+
const DANGEROUS_TOOLS = new Set([
|
|
26
|
+
"run_command", "write_file", "file_edit", "delete_file",
|
|
27
|
+
"spawn_subagent", "browser_navigate",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Tools that ALWAYS require approval (even in yolo mode)
|
|
31
|
+
const ALWAYS_APPROVE = new Set(["delete_file"]);
|
|
32
|
+
|
|
33
|
+
// System-path prefixes — writing here requires approval even in balanced mode
|
|
34
|
+
const SYSTEM_PATH_PREFIXES = ["/usr", "/etc", "/System", "/bin", "/sbin", "/Library/LaunchDaemons"];
|
|
35
|
+
|
|
36
|
+
const APPROVALS_PATH = path.join(os.homedir(), ".wispy", "approvals.json");
|
|
37
|
+
|
|
38
|
+
const DEFAULT_ALLOWLIST = {
|
|
39
|
+
run_command: ["npm test", "npm run build", "git status", "git diff", "ls"],
|
|
40
|
+
write_file: [],
|
|
41
|
+
file_edit: [],
|
|
42
|
+
delete_file: [],
|
|
43
|
+
spawn_subagent: [],
|
|
44
|
+
browser_navigate: [],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ── Allowlist manager ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export class ApprovalAllowlist {
|
|
50
|
+
constructor() {
|
|
51
|
+
this._list = null; // lazy load
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async _load() {
|
|
55
|
+
if (this._list !== null) return;
|
|
56
|
+
try {
|
|
57
|
+
const raw = await readFile(APPROVALS_PATH, "utf8");
|
|
58
|
+
this._list = JSON.parse(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
this._list = { ...DEFAULT_ALLOWLIST };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async _save() {
|
|
65
|
+
await mkdir(path.dirname(APPROVALS_PATH), { recursive: true });
|
|
66
|
+
await writeFile(APPROVALS_PATH, JSON.stringify(this._list, null, 2) + "\n", "utf8");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async matches(toolName, args) {
|
|
70
|
+
await this._load();
|
|
71
|
+
const patterns = this._list[toolName] ?? [];
|
|
72
|
+
if (patterns.length === 0) return false;
|
|
73
|
+
|
|
74
|
+
// Get a string representation of args for matching
|
|
75
|
+
const argStr = _getArgString(toolName, args);
|
|
76
|
+
if (!argStr) return false;
|
|
77
|
+
|
|
78
|
+
return patterns.some(pattern => {
|
|
79
|
+
// Glob-style: "*" matches everything
|
|
80
|
+
if (pattern === "*") return true;
|
|
81
|
+
// Prefix match or exact match
|
|
82
|
+
if (pattern.endsWith("*")) return argStr.startsWith(pattern.slice(0, -1));
|
|
83
|
+
return argStr === pattern || argStr.startsWith(pattern);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async add(toolName, pattern) {
|
|
88
|
+
await this._load();
|
|
89
|
+
if (!this._list[toolName]) this._list[toolName] = [];
|
|
90
|
+
if (!this._list[toolName].includes(pattern)) {
|
|
91
|
+
this._list[toolName].push(pattern);
|
|
92
|
+
await this._save();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async clear() {
|
|
97
|
+
this._list = {};
|
|
98
|
+
await this._save();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async reset() {
|
|
102
|
+
this._list = { ...DEFAULT_ALLOWLIST };
|
|
103
|
+
await this._save();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getAll() {
|
|
107
|
+
await this._load();
|
|
108
|
+
return { ...this._list };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Singleton allowlist instance
|
|
113
|
+
const globalAllowlist = new ApprovalAllowlist();
|
|
114
|
+
|
|
115
|
+
function _getArgString(toolName, args) {
|
|
116
|
+
if (!args) return "";
|
|
117
|
+
if (toolName === "run_command") return args.command ?? "";
|
|
118
|
+
if (toolName === "write_file" || toolName === "file_edit") return args.path ?? "";
|
|
119
|
+
if (toolName === "delete_file") return args.path ?? "";
|
|
120
|
+
if (toolName === "browser_navigate") return args.url ?? "";
|
|
121
|
+
if (toolName === "spawn_subagent") return args.task ?? "";
|
|
122
|
+
return JSON.stringify(args);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Determine if a tool+args needs approval based on security mode.
|
|
127
|
+
* @param {string} toolName
|
|
128
|
+
* @param {object} args
|
|
129
|
+
* @param {string} mode - "careful" | "balanced" | "yolo"
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
function _needsApproval(toolName, args, mode) {
|
|
133
|
+
if (ALWAYS_APPROVE.has(toolName)) return true;
|
|
134
|
+
if (!DANGEROUS_TOOLS.has(toolName)) return false;
|
|
135
|
+
|
|
136
|
+
if (mode === "careful") return true;
|
|
137
|
+
|
|
138
|
+
if (mode === "balanced") {
|
|
139
|
+
// Only destructive tools or writes to system paths
|
|
140
|
+
if (toolName === "delete_file") return true;
|
|
141
|
+
if (toolName === "write_file" || toolName === "file_edit") {
|
|
142
|
+
const filePath = args?.path ?? "";
|
|
143
|
+
const resolved = filePath.replace(/^~/, os.homedir());
|
|
144
|
+
return SYSTEM_PATH_PREFIXES.some(prefix => resolved.startsWith(prefix));
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// yolo: only ALWAYS_APPROVE (handled above)
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { globalAllowlist };
|
|
154
|
+
|
|
22
155
|
// ── Receipt ────────────────────────────────────────────────────────────────────
|
|
23
156
|
|
|
24
157
|
export class Receipt {
|
|
@@ -349,6 +482,7 @@ export class Harness extends EventEmitter {
|
|
|
349
482
|
this.permissions = permissions;
|
|
350
483
|
this.audit = audit;
|
|
351
484
|
this.config = config;
|
|
485
|
+
this.allowlist = globalAllowlist;
|
|
352
486
|
|
|
353
487
|
// Sandbox config per-tool: "preview" | "diff" | null
|
|
354
488
|
this._sandboxModes = {
|
|
@@ -402,6 +536,56 @@ export class Harness extends EventEmitter {
|
|
|
402
536
|
receipt.approved = permResult.approved;
|
|
403
537
|
}
|
|
404
538
|
|
|
539
|
+
// ── 1b. Approval gate (security mode) ────────────────────────────────────
|
|
540
|
+
const securityMode = this.config.securityLevel ?? context.securityLevel ?? "balanced";
|
|
541
|
+
if (_needsApproval(toolName, args, securityMode) && permResult.allowed) {
|
|
542
|
+
// Check allowlist first
|
|
543
|
+
const allowlisted = await this.allowlist.matches(toolName, args);
|
|
544
|
+
if (!allowlisted) {
|
|
545
|
+
// Non-interactive (no TTY): auto-deny in careful, auto-allow in balanced/yolo
|
|
546
|
+
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
547
|
+
if (!isTTY) {
|
|
548
|
+
if (securityMode === "careful") {
|
|
549
|
+
receipt.approved = false;
|
|
550
|
+
receipt.success = false;
|
|
551
|
+
receipt.error = `Auto-denied (careful mode, non-interactive): ${toolName}`;
|
|
552
|
+
receipt.duration = Date.now() - callStart;
|
|
553
|
+
this.emit("tool:denied", { toolName, args, receipt, context, reason: "non-interactive-careful" });
|
|
554
|
+
return new HarnessResult({ result: { success: false, error: receipt.error, denied: true }, receipt, denied: true });
|
|
555
|
+
}
|
|
556
|
+
// balanced/yolo non-interactive → auto-allow
|
|
557
|
+
receipt.approved = true;
|
|
558
|
+
} else {
|
|
559
|
+
// Emit approval_required event and wait for response
|
|
560
|
+
const approved = await this._requestApproval(toolName, args, receipt, context, securityMode);
|
|
561
|
+
receipt.approved = approved;
|
|
562
|
+
if (!approved) {
|
|
563
|
+
receipt.success = false;
|
|
564
|
+
receipt.error = `Approval denied for: ${toolName}`;
|
|
565
|
+
receipt.duration = Date.now() - callStart;
|
|
566
|
+
this.audit.log({
|
|
567
|
+
type: EVENT_TYPES.APPROVAL_DENIED,
|
|
568
|
+
sessionId: context.sessionId,
|
|
569
|
+
tool: toolName,
|
|
570
|
+
args,
|
|
571
|
+
}).catch(() => {});
|
|
572
|
+
this.emit("tool:denied", { toolName, args, receipt, context, reason: "approval-denied" });
|
|
573
|
+
return new HarnessResult({ result: { success: false, error: receipt.error, denied: true }, receipt, denied: true });
|
|
574
|
+
}
|
|
575
|
+
// User approved — add to allowlist if they want to remember
|
|
576
|
+
// (The auto-approve-and-remember logic is handled by the approval handler)
|
|
577
|
+
this.audit.log({
|
|
578
|
+
type: EVENT_TYPES.APPROVAL_GRANTED ?? "approval_granted",
|
|
579
|
+
sessionId: context.sessionId,
|
|
580
|
+
tool: toolName,
|
|
581
|
+
args,
|
|
582
|
+
}).catch(() => {});
|
|
583
|
+
}
|
|
584
|
+
} else {
|
|
585
|
+
receipt.approved = true; // allowlisted
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
405
589
|
// ── 2. Dry-run mode ──────────────────────────────────────────────────────
|
|
406
590
|
if (receipt.dryRun) {
|
|
407
591
|
const preview = simulateDryRun(toolName, args);
|
|
@@ -516,6 +700,43 @@ export class Harness extends EventEmitter {
|
|
|
516
700
|
return new HarnessResult({ result, receipt });
|
|
517
701
|
}
|
|
518
702
|
|
|
703
|
+
/**
|
|
704
|
+
* Emit 'approval_required' and wait for user response.
|
|
705
|
+
* Resolves to true (approved) or false (denied).
|
|
706
|
+
*/
|
|
707
|
+
async _requestApproval(toolName, args, receipt, context, mode) {
|
|
708
|
+
return new Promise((resolve) => {
|
|
709
|
+
// Timeout after 60s → auto-deny in careful, auto-allow otherwise
|
|
710
|
+
const timer = setTimeout(() => {
|
|
711
|
+
if (mode === "careful") {
|
|
712
|
+
resolve(false);
|
|
713
|
+
} else {
|
|
714
|
+
resolve(true);
|
|
715
|
+
}
|
|
716
|
+
}, 60_000);
|
|
717
|
+
|
|
718
|
+
const respond = (approved, remember = false) => {
|
|
719
|
+
clearTimeout(timer);
|
|
720
|
+
if (remember && approved) {
|
|
721
|
+
const pattern = _getArgString(toolName, args);
|
|
722
|
+
if (pattern) {
|
|
723
|
+
this.allowlist.add(toolName, pattern).catch(() => {});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
resolve(approved);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
this.emit("approval_required", {
|
|
730
|
+
tool: toolName,
|
|
731
|
+
args,
|
|
732
|
+
receipt,
|
|
733
|
+
context,
|
|
734
|
+
mode,
|
|
735
|
+
respond,
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
519
740
|
/**
|
|
520
741
|
* Set sandbox mode for a tool.
|
|
521
742
|
* @param {string} toolName
|
package/core/index.mjs
CHANGED
|
@@ -9,7 +9,8 @@ export { SessionManager, Session, sessionManager } from "./session.mjs";
|
|
|
9
9
|
export { ProviderRegistry } from "./providers.mjs";
|
|
10
10
|
export { ToolRegistry } from "./tools.mjs";
|
|
11
11
|
export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
|
|
12
|
-
export { loadConfig, saveConfig, detectProvider, isFirstRun, PROVIDERS, WISPY_DIR, CONFIG_PATH, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
|
|
12
|
+
export { loadConfig, saveConfig, loadConfigWithProfile, listProfiles, detectProvider, isFirstRun, PROVIDERS, WISPY_DIR, CONFIG_PATH, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
|
|
13
|
+
export { FeatureManager, getFeatureManager, FEATURE_REGISTRY } from "./features.mjs";
|
|
13
14
|
export { OnboardingWizard, isFirstRun as checkFirstRun, printStatus } from "./onboarding.mjs";
|
|
14
15
|
export { MemoryManager } from "./memory.mjs";
|
|
15
16
|
export { CronManager } from "./cron.mjs";
|
package/core/providers.mjs
CHANGED
|
@@ -169,6 +169,16 @@ export class ProviderRegistry {
|
|
|
169
169
|
functionCall: { name: tc.name, args: tc.args },
|
|
170
170
|
})),
|
|
171
171
|
});
|
|
172
|
+
} else if (m.images && m.images.length > 0) {
|
|
173
|
+
// Multimodal message with images (Google format)
|
|
174
|
+
const parts = m.images.map(img => ({
|
|
175
|
+
inlineData: { mimeType: img.mimeType, data: img.data },
|
|
176
|
+
}));
|
|
177
|
+
if (m.content) parts.push({ text: m.content });
|
|
178
|
+
contents.push({
|
|
179
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
180
|
+
parts,
|
|
181
|
+
});
|
|
172
182
|
} else {
|
|
173
183
|
contents.push({
|
|
174
184
|
role: m.role === "assistant" ? "model" : "user",
|
|
@@ -280,6 +290,17 @@ export class ProviderRegistry {
|
|
|
280
290
|
type: "tool_use", id: tc.id ?? tc.name, name: tc.name, input: tc.args,
|
|
281
291
|
})),
|
|
282
292
|
});
|
|
293
|
+
} else if (m.images && m.images.length > 0) {
|
|
294
|
+
// Multimodal message with images (Anthropic format)
|
|
295
|
+
const contentParts = m.images.map(img => ({
|
|
296
|
+
type: "image",
|
|
297
|
+
source: { type: "base64", media_type: img.mimeType, data: img.data },
|
|
298
|
+
}));
|
|
299
|
+
if (m.content) contentParts.push({ type: "text", text: m.content });
|
|
300
|
+
anthropicMessages.push({
|
|
301
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
302
|
+
content: contentParts,
|
|
303
|
+
});
|
|
283
304
|
} else {
|
|
284
305
|
anthropicMessages.push({
|
|
285
306
|
role: m.role === "assistant" ? "assistant" : "user",
|
|
@@ -390,6 +411,15 @@ export class ProviderRegistry {
|
|
|
390
411
|
})),
|
|
391
412
|
};
|
|
392
413
|
}
|
|
414
|
+
// Multimodal message with images (OpenAI format)
|
|
415
|
+
if (m.images && m.images.length > 0) {
|
|
416
|
+
const contentParts = m.images.map(img => ({
|
|
417
|
+
type: "image_url",
|
|
418
|
+
image_url: { url: `data:${img.mimeType};base64,${img.data}` },
|
|
419
|
+
}));
|
|
420
|
+
if (m.content) contentParts.push({ type: "text", text: m.content });
|
|
421
|
+
return { role: m.role === "assistant" ? "assistant" : "user", content: contentParts };
|
|
422
|
+
}
|
|
393
423
|
return { role: m.role, content: m.content };
|
|
394
424
|
});
|
|
395
425
|
|
package/core/session.mjs
CHANGED
|
@@ -93,6 +93,109 @@ export class SessionManager {
|
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* List all sessions from disk with metadata (for CLI listing / picking).
|
|
98
|
+
* Returns sorted by updatedAt descending (most recent first).
|
|
99
|
+
*
|
|
100
|
+
* @param {object} options
|
|
101
|
+
* @param {string} [options.workstream] - Filter by workstream (default: current dir sessions only via all=false)
|
|
102
|
+
* @param {boolean} [options.all] - Include sessions from all workstreams
|
|
103
|
+
* @param {number} [options.limit] - Max sessions to return (default: 50)
|
|
104
|
+
* @returns {Array<{id, workstream, channel, chatId, createdAt, updatedAt, model, messageCount, firstMessage, cwd}>}
|
|
105
|
+
*/
|
|
106
|
+
async listSessions(options = {}) {
|
|
107
|
+
const { workstream = null, all: showAll = false, limit = 50 } = options;
|
|
108
|
+
let files;
|
|
109
|
+
try {
|
|
110
|
+
files = await readdir(SESSIONS_DIR);
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sessionFiles = files.filter(f => f.endsWith(".json"));
|
|
116
|
+
const results = [];
|
|
117
|
+
|
|
118
|
+
for (const file of sessionFiles) {
|
|
119
|
+
const id = file.replace(".json", "");
|
|
120
|
+
const filePath = path.join(SESSIONS_DIR, file);
|
|
121
|
+
try {
|
|
122
|
+
const [raw, fileStat] = await Promise.all([
|
|
123
|
+
readFile(filePath, "utf8"),
|
|
124
|
+
stat(filePath),
|
|
125
|
+
]);
|
|
126
|
+
let data;
|
|
127
|
+
try { data = JSON.parse(raw); } catch { continue; }
|
|
128
|
+
if (!data || !data.id) continue;
|
|
129
|
+
|
|
130
|
+
// Apply workstream filter
|
|
131
|
+
if (!showAll && workstream && data.workstream !== workstream) continue;
|
|
132
|
+
|
|
133
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
134
|
+
const userMessages = messages.filter(m => m.role === "user");
|
|
135
|
+
const firstMessage = userMessages[0]?.content ?? messages[0]?.content ?? "";
|
|
136
|
+
|
|
137
|
+
results.push({
|
|
138
|
+
id: data.id,
|
|
139
|
+
workstream: data.workstream ?? "default",
|
|
140
|
+
channel: data.channel ?? null,
|
|
141
|
+
chatId: data.chatId ?? null,
|
|
142
|
+
createdAt: data.createdAt ?? fileStat.birthtime.toISOString(),
|
|
143
|
+
updatedAt: data.updatedAt ?? fileStat.mtime.toISOString(),
|
|
144
|
+
model: data.model ?? null,
|
|
145
|
+
messageCount: messages.length,
|
|
146
|
+
firstMessage: firstMessage.slice(0, 120),
|
|
147
|
+
cwd: data.cwd ?? null,
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sort by updatedAt descending
|
|
155
|
+
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
156
|
+
return results.slice(0, limit);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Load a session by ID and return the full session (alias for load with better name).
|
|
161
|
+
* Returns null if not found.
|
|
162
|
+
*/
|
|
163
|
+
async loadSession(id) {
|
|
164
|
+
return this.getOrLoad(id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Fork a session: copy its message history into a new session.
|
|
169
|
+
* The new session starts from the same history but diverges from here.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} id - Source session ID
|
|
172
|
+
* @param {object} opts - Additional options for the new session (workstream, etc.)
|
|
173
|
+
* @returns {Session} - The new forked session
|
|
174
|
+
*/
|
|
175
|
+
async forkSession(id, opts = {}) {
|
|
176
|
+
// Load source session
|
|
177
|
+
const source = await this.getOrLoad(id);
|
|
178
|
+
if (!source) {
|
|
179
|
+
throw new Error(`Session not found: ${id}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Create a new session with same metadata
|
|
183
|
+
const forked = this.create({
|
|
184
|
+
workstream: opts.workstream ?? source.workstream,
|
|
185
|
+
channel: opts.channel ?? null,
|
|
186
|
+
chatId: opts.chatId ?? null,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Copy message history (deep copy)
|
|
190
|
+
forked.messages = source.messages.map(m => ({ ...m }));
|
|
191
|
+
forked.updatedAt = new Date().toISOString();
|
|
192
|
+
|
|
193
|
+
// Save the forked session
|
|
194
|
+
await this.save(forked.id);
|
|
195
|
+
|
|
196
|
+
return forked;
|
|
197
|
+
}
|
|
198
|
+
|
|
96
199
|
/**
|
|
97
200
|
* Add a message to a session.
|
|
98
201
|
*/
|
package/core/tools.mjs
CHANGED
|
@@ -326,6 +326,21 @@ export class ToolRegistry {
|
|
|
326
326
|
},
|
|
327
327
|
},
|
|
328
328
|
},
|
|
329
|
+
// ── apply_patch (Part 3) ─────────────────────────────────────────────────
|
|
330
|
+
{
|
|
331
|
+
name: "apply_patch",
|
|
332
|
+
description: "Apply multi-file patches. Supports creating, editing, and deleting files in one atomic operation. All operations succeed or all rollback.",
|
|
333
|
+
parameters: {
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: {
|
|
336
|
+
patch: {
|
|
337
|
+
type: "string",
|
|
338
|
+
description: "Patch in structured format:\n*** Begin Patch\n*** Add File: path\n+line1\n+line2\n*** Edit File: path\n@@@ context line @@@\n-old line\n+new line\n*** Delete File: path\n*** End Patch",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
required: ["patch"],
|
|
342
|
+
},
|
|
343
|
+
},
|
|
329
344
|
];
|
|
330
345
|
|
|
331
346
|
for (const def of builtins) {
|
|
@@ -631,6 +646,9 @@ export class ToolRegistry {
|
|
|
631
646
|
return { success: false, error: "action must be 'copy' or 'paste'" };
|
|
632
647
|
}
|
|
633
648
|
|
|
649
|
+
case "apply_patch":
|
|
650
|
+
return this._executeApplyPatch(args.patch);
|
|
651
|
+
|
|
634
652
|
// Agent tools — these are handled by the engine level
|
|
635
653
|
case "spawn_agent":
|
|
636
654
|
case "list_agents":
|
|
@@ -656,10 +674,195 @@ export class ToolRegistry {
|
|
|
656
674
|
return { success: false, error: `Tool "${name}" requires engine context. Call via WispyEngine.processMessage().` };
|
|
657
675
|
|
|
658
676
|
default:
|
|
659
|
-
return { success: false, error: `Unknown tool: ${name}. Available built-in tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard` };
|
|
677
|
+
return { success: false, error: `Unknown tool: ${name}. Available built-in tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, apply_patch` };
|
|
660
678
|
}
|
|
661
679
|
} catch (err) {
|
|
662
680
|
return { success: false, error: err.message };
|
|
663
681
|
}
|
|
664
682
|
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Execute an apply_patch operation atomically.
|
|
686
|
+
* Supports: Add File, Edit File, Delete File
|
|
687
|
+
*
|
|
688
|
+
* Format:
|
|
689
|
+
* *** Begin Patch
|
|
690
|
+
* *** Add File: path/to/file.txt
|
|
691
|
+
* +line content
|
|
692
|
+
* *** Edit File: path/to/file.txt
|
|
693
|
+
* @@@ context @@@
|
|
694
|
+
* -old line
|
|
695
|
+
* +new line
|
|
696
|
+
* *** Delete File: path/to/file.txt
|
|
697
|
+
* *** End Patch
|
|
698
|
+
*/
|
|
699
|
+
async _executeApplyPatch(patchText) {
|
|
700
|
+
if (!patchText || typeof patchText !== "string") {
|
|
701
|
+
return { success: false, error: "patch parameter is required and must be a string" };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const { readFile: rf, writeFile: wf, unlink, mkdir: mkdirFs } = await import("node:fs/promises");
|
|
705
|
+
|
|
706
|
+
const lines = patchText.split("\n");
|
|
707
|
+
const operations = [];
|
|
708
|
+
|
|
709
|
+
// ── Parse operations ──────────────────────────────────────────────────────
|
|
710
|
+
let i = 0;
|
|
711
|
+
// Skip to Begin Patch
|
|
712
|
+
while (i < lines.length && !lines[i].startsWith("*** Begin Patch")) i++;
|
|
713
|
+
if (i >= lines.length) {
|
|
714
|
+
return { success: false, error: 'Patch must start with "*** Begin Patch"' };
|
|
715
|
+
}
|
|
716
|
+
i++;
|
|
717
|
+
|
|
718
|
+
while (i < lines.length) {
|
|
719
|
+
const line = lines[i];
|
|
720
|
+
|
|
721
|
+
if (line.startsWith("*** End Patch")) break;
|
|
722
|
+
|
|
723
|
+
if (line.startsWith("*** Add File:")) {
|
|
724
|
+
const filePath = line.slice("*** Add File:".length).trim();
|
|
725
|
+
const addLines = [];
|
|
726
|
+
i++;
|
|
727
|
+
while (i < lines.length && !lines[i].startsWith("***")) {
|
|
728
|
+
const l = lines[i];
|
|
729
|
+
if (l.startsWith("+")) addLines.push(l.slice(1));
|
|
730
|
+
else if (l.startsWith(" ")) addLines.push(l.slice(1));
|
|
731
|
+
// Lines starting with - are ignored for Add File
|
|
732
|
+
i++;
|
|
733
|
+
}
|
|
734
|
+
operations.push({ type: "add", path: filePath, content: addLines.join("\n") });
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (line.startsWith("*** Edit File:")) {
|
|
739
|
+
const filePath = line.slice("*** Edit File:".length).trim();
|
|
740
|
+
const hunks = [];
|
|
741
|
+
i++;
|
|
742
|
+
// Parse edit hunks
|
|
743
|
+
while (i < lines.length && !lines[i].startsWith("*** ")) {
|
|
744
|
+
const hunkLine = lines[i];
|
|
745
|
+
if (hunkLine.startsWith("@@@")) {
|
|
746
|
+
// Start of a hunk: @@@ context @@@
|
|
747
|
+
const contextText = hunkLine.replace(/^@@@\s*/, "").replace(/\s*@@@$/, "").trim();
|
|
748
|
+
const removals = [];
|
|
749
|
+
const additions = [];
|
|
750
|
+
i++;
|
|
751
|
+
while (i < lines.length && !lines[i].startsWith("@@@") && !lines[i].startsWith("***")) {
|
|
752
|
+
const hl = lines[i];
|
|
753
|
+
if (hl.startsWith("-")) removals.push(hl.slice(1));
|
|
754
|
+
else if (hl.startsWith("+")) additions.push(hl.slice(1));
|
|
755
|
+
i++;
|
|
756
|
+
}
|
|
757
|
+
hunks.push({ context: contextText, removals, additions });
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
i++;
|
|
761
|
+
}
|
|
762
|
+
operations.push({ type: "edit", path: filePath, hunks });
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (line.startsWith("*** Delete File:")) {
|
|
767
|
+
const filePath = line.slice("*** Delete File:".length).trim();
|
|
768
|
+
operations.push({ type: "delete", path: filePath });
|
|
769
|
+
i++;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
i++;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (operations.length === 0) {
|
|
777
|
+
return { success: false, error: "No valid operations found in patch" };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ── Resolve paths ─────────────────────────────────────────────────────────
|
|
781
|
+
const resolvePatchPath = (p) => {
|
|
782
|
+
let resolved = p.replace(/^~/, os.homedir());
|
|
783
|
+
if (!path.isAbsolute(resolved)) resolved = path.resolve(process.cwd(), resolved);
|
|
784
|
+
return resolved;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// ── Pre-flight: load original file contents for rollback ──────────────────
|
|
788
|
+
const originalContents = new Map(); // path → string | null (null = didn't exist)
|
|
789
|
+
for (const op of operations) {
|
|
790
|
+
const resolved = resolvePatchPath(op.path);
|
|
791
|
+
try {
|
|
792
|
+
originalContents.set(resolved, await rf(resolved, "utf8"));
|
|
793
|
+
} catch {
|
|
794
|
+
originalContents.set(resolved, null);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ── Apply operations ──────────────────────────────────────────────────────
|
|
799
|
+
const applied = [];
|
|
800
|
+
const results = [];
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
for (const op of operations) {
|
|
804
|
+
const resolved = resolvePatchPath(op.path);
|
|
805
|
+
|
|
806
|
+
if (op.type === "add") {
|
|
807
|
+
await mkdirFs(path.dirname(resolved), { recursive: true });
|
|
808
|
+
await wf(resolved, op.content, "utf8");
|
|
809
|
+
applied.push({ op, resolved });
|
|
810
|
+
results.push(`✅ Added: ${op.path}`);
|
|
811
|
+
|
|
812
|
+
} else if (op.type === "edit") {
|
|
813
|
+
const original = originalContents.get(resolved);
|
|
814
|
+
if (original === null) {
|
|
815
|
+
throw new Error(`Cannot edit non-existent file: ${op.path}`);
|
|
816
|
+
}
|
|
817
|
+
let current = original;
|
|
818
|
+
for (const hunk of op.hunks) {
|
|
819
|
+
const oldText = hunk.removals.join("\n");
|
|
820
|
+
const newText = hunk.additions.join("\n");
|
|
821
|
+
if (oldText && !current.includes(oldText)) {
|
|
822
|
+
throw new Error(`Edit hunk not found in ${op.path}: "${oldText.slice(0, 60)}"`);
|
|
823
|
+
}
|
|
824
|
+
current = oldText ? current.replace(oldText, newText) : current + "\n" + newText;
|
|
825
|
+
}
|
|
826
|
+
await wf(resolved, current, "utf8");
|
|
827
|
+
applied.push({ op, resolved });
|
|
828
|
+
results.push(`✅ Edited: ${op.path}`);
|
|
829
|
+
|
|
830
|
+
} else if (op.type === "delete") {
|
|
831
|
+
await unlink(resolved);
|
|
832
|
+
applied.push({ op, resolved });
|
|
833
|
+
results.push(`✅ Deleted: ${op.path}`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
} catch (err) {
|
|
837
|
+
// ── Rollback all applied operations ──────────────────────────────────────
|
|
838
|
+
for (const { op, resolved } of applied.reverse()) {
|
|
839
|
+
try {
|
|
840
|
+
const original = originalContents.get(resolved);
|
|
841
|
+
if (op.type === "delete" && original !== null) {
|
|
842
|
+
// Restore deleted file
|
|
843
|
+
await wf(resolved, original, "utf8");
|
|
844
|
+
} else if ((op.type === "add") && original === null) {
|
|
845
|
+
// Remove newly created file
|
|
846
|
+
await unlink(resolved).catch(() => {});
|
|
847
|
+
} else if (op.type === "edit" && original !== null) {
|
|
848
|
+
// Restore original content
|
|
849
|
+
await wf(resolved, original, "utf8");
|
|
850
|
+
}
|
|
851
|
+
} catch { /* best-effort rollback */ }
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
success: false,
|
|
856
|
+
error: `Patch failed (rolled back): ${err.message}`,
|
|
857
|
+
applied: applied.length,
|
|
858
|
+
total: operations.length,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
success: true,
|
|
864
|
+
message: `Applied ${operations.length} operation(s)`,
|
|
865
|
+
results,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
665
868
|
}
|