wispy-cli 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/config.mjs +104 -0
- package/core/engine.mjs +532 -0
- package/core/index.mjs +12 -0
- package/core/mcp.mjs +8 -0
- package/core/providers.mjs +410 -0
- package/core/session.mjs +196 -0
- package/core/tools.mjs +526 -0
- package/lib/channels/index.mjs +45 -246
- package/lib/wispy-repl.mjs +332 -2447
- package/lib/wispy-tui.mjs +105 -588
- package/package.json +2 -1
package/core/tools.mjs
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/tools.mjs — Tool registry for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Class ToolRegistry:
|
|
5
|
+
* - registerBuiltin() — read_file, write_file, run_command, list_directory, git, web_search, etc.
|
|
6
|
+
* - registerMCP(mcpManager) — merge MCP tools
|
|
7
|
+
* - getDefinitions() → tool definitions array
|
|
8
|
+
* - execute(name, args) → result
|
|
9
|
+
* - hasTool(name) → boolean
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
15
|
+
|
|
16
|
+
// Default server port for optional sandboxed server
|
|
17
|
+
const DEFAULT_SERVER_PORT = process.env.AWOS_PORT ?? "8090";
|
|
18
|
+
|
|
19
|
+
export class ToolRegistry {
|
|
20
|
+
constructor() {
|
|
21
|
+
this._definitions = new Map(); // name → definition
|
|
22
|
+
this._mcpManager = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register all built-in tools.
|
|
27
|
+
*/
|
|
28
|
+
registerBuiltin() {
|
|
29
|
+
const builtins = [
|
|
30
|
+
{
|
|
31
|
+
name: "read_file",
|
|
32
|
+
description: "Read the contents of a file at the given path",
|
|
33
|
+
parameters: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: { path: { type: "string", description: "File path to read" } },
|
|
36
|
+
required: ["path"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "write_file",
|
|
41
|
+
description: "Write content to a file, creating it if it doesn't exist",
|
|
42
|
+
parameters: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
path: { type: "string", description: "File path to write" },
|
|
46
|
+
content: { type: "string", description: "Content to write" },
|
|
47
|
+
},
|
|
48
|
+
required: ["path", "content"],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "run_command",
|
|
53
|
+
description: "Execute a shell command and return stdout/stderr",
|
|
54
|
+
parameters: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: { command: { type: "string", description: "Shell command to execute" } },
|
|
57
|
+
required: ["command"],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "list_directory",
|
|
62
|
+
description: "List files and directories at the given path",
|
|
63
|
+
parameters: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: { path: { type: "string", description: "Directory path (default: current dir)" } },
|
|
66
|
+
required: [],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "web_search",
|
|
71
|
+
description: "Search the web and return results",
|
|
72
|
+
parameters: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: { query: { type: "string", description: "Search query" } },
|
|
75
|
+
required: ["query"],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "file_edit",
|
|
80
|
+
description: "Edit a file by replacing specific text. More precise than write_file — use this for targeted changes.",
|
|
81
|
+
parameters: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
path: { type: "string", description: "File path" },
|
|
85
|
+
old_text: { type: "string", description: "Exact text to find and replace" },
|
|
86
|
+
new_text: { type: "string", description: "Replacement text" },
|
|
87
|
+
},
|
|
88
|
+
required: ["path", "old_text", "new_text"],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "file_search",
|
|
93
|
+
description: "Search for text patterns in files recursively (like grep).",
|
|
94
|
+
parameters: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
pattern: { type: "string", description: "Text or regex pattern to search for" },
|
|
98
|
+
path: { type: "string", description: "Directory to search in (default: current dir)" },
|
|
99
|
+
file_glob: { type: "string", description: "File glob filter (e.g., '*.ts', '*.py')" },
|
|
100
|
+
},
|
|
101
|
+
required: ["pattern"],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "git",
|
|
106
|
+
description: "Run git operations: status, diff, log, branch, add, commit, stash, checkout.",
|
|
107
|
+
parameters: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: { command: { type: "string", description: "Git subcommand and args" } },
|
|
110
|
+
required: ["command"],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "web_fetch",
|
|
115
|
+
description: "Fetch content from a URL and return it as text/markdown.",
|
|
116
|
+
parameters: {
|
|
117
|
+
type: "object",
|
|
118
|
+
properties: { url: { type: "string", description: "URL to fetch" } },
|
|
119
|
+
required: ["url"],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "keychain",
|
|
124
|
+
description: "Manage macOS Keychain secrets.",
|
|
125
|
+
parameters: {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: {
|
|
128
|
+
action: { type: "string", enum: ["get", "set", "delete", "list"] },
|
|
129
|
+
service: { type: "string" },
|
|
130
|
+
account: { type: "string" },
|
|
131
|
+
value: { type: "string" },
|
|
132
|
+
},
|
|
133
|
+
required: ["action", "service"],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "clipboard",
|
|
138
|
+
description: "Copy text to clipboard or read current clipboard contents.",
|
|
139
|
+
parameters: {
|
|
140
|
+
type: "object",
|
|
141
|
+
properties: {
|
|
142
|
+
action: { type: "string", enum: ["copy", "paste"] },
|
|
143
|
+
text: { type: "string" },
|
|
144
|
+
},
|
|
145
|
+
required: ["action"],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "spawn_agent",
|
|
150
|
+
description: "Spawn a sub-agent for a well-scoped task.",
|
|
151
|
+
parameters: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
task: { type: "string" },
|
|
155
|
+
role: { type: "string", enum: ["explorer", "planner", "worker", "reviewer"] },
|
|
156
|
+
model_tier: { type: "string", enum: ["cheap", "mid", "expensive"] },
|
|
157
|
+
fork_context: { type: "boolean" },
|
|
158
|
+
},
|
|
159
|
+
required: ["task", "role"],
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "list_agents",
|
|
164
|
+
description: "List all running/completed sub-agents and their status",
|
|
165
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "get_agent_result",
|
|
169
|
+
description: "Get the result from a completed sub-agent",
|
|
170
|
+
parameters: {
|
|
171
|
+
type: "object",
|
|
172
|
+
properties: { agent_id: { type: "string" } },
|
|
173
|
+
required: ["agent_id"],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: "update_plan",
|
|
178
|
+
description: "Create or update a step-by-step plan for the current task.",
|
|
179
|
+
parameters: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
explanation: { type: "string" },
|
|
183
|
+
steps: { type: "array", items: { type: "object", properties: { step: { type: "string" }, status: { type: "string" } } } },
|
|
184
|
+
},
|
|
185
|
+
required: ["steps"],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "pipeline",
|
|
190
|
+
description: "Run a sequential pipeline of agent roles.",
|
|
191
|
+
parameters: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
task: { type: "string" },
|
|
195
|
+
stages: { type: "array", items: { type: "string", enum: ["explorer", "planner", "worker", "reviewer"] } },
|
|
196
|
+
},
|
|
197
|
+
required: ["task", "stages"],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "spawn_async_agent",
|
|
202
|
+
description: "Spawn a sub-agent that runs in the background.",
|
|
203
|
+
parameters: {
|
|
204
|
+
type: "object",
|
|
205
|
+
properties: {
|
|
206
|
+
task: { type: "string" },
|
|
207
|
+
role: { type: "string", enum: ["explorer", "planner", "worker", "reviewer"] },
|
|
208
|
+
},
|
|
209
|
+
required: ["task", "role"],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: "ralph_loop",
|
|
214
|
+
description: "Persistence mode — keep retrying a task until it's verified complete.",
|
|
215
|
+
parameters: {
|
|
216
|
+
type: "object",
|
|
217
|
+
properties: {
|
|
218
|
+
task: { type: "string" },
|
|
219
|
+
success_criteria: { type: "string" },
|
|
220
|
+
},
|
|
221
|
+
required: ["task"],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
for (const def of builtins) {
|
|
227
|
+
this._definitions.set(def.name, def);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Register MCP tools from an MCPManager instance.
|
|
233
|
+
*/
|
|
234
|
+
registerMCP(mcpManager) {
|
|
235
|
+
this._mcpManager = mcpManager;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get all tool definitions (builtin + MCP).
|
|
240
|
+
*/
|
|
241
|
+
getDefinitions() {
|
|
242
|
+
const defs = Array.from(this._definitions.values());
|
|
243
|
+
if (this._mcpManager) {
|
|
244
|
+
defs.push(...this._mcpManager.getToolDefinitions());
|
|
245
|
+
}
|
|
246
|
+
return defs;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
hasTool(name) {
|
|
250
|
+
if (this._definitions.has(name)) return true;
|
|
251
|
+
if (this._mcpManager?.hasTool(name)) return true;
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Execute a tool by name with args.
|
|
257
|
+
*/
|
|
258
|
+
async execute(name, args) {
|
|
259
|
+
// Try server-based execution first for file ops
|
|
260
|
+
const serverResult = await this._executeViaServer(name, args);
|
|
261
|
+
if (serverResult) return serverResult;
|
|
262
|
+
|
|
263
|
+
// MCP tools
|
|
264
|
+
if (this._mcpManager?.hasTool(name)) {
|
|
265
|
+
try {
|
|
266
|
+
const result = await this._mcpManager.callTool(name, args);
|
|
267
|
+
if (result?.isError) {
|
|
268
|
+
const errText = result.content?.map(c => c.text ?? "").join("") ?? "MCP tool error";
|
|
269
|
+
return { success: false, error: errText };
|
|
270
|
+
}
|
|
271
|
+
const output = result?.content?.map(c => c.text ?? c.data ?? JSON.stringify(c)).join("\n") ?? JSON.stringify(result);
|
|
272
|
+
return { success: true, output };
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return { success: false, error: `MCP tool error: ${err.message}` };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Built-in tools
|
|
279
|
+
return this._executeBuiltin(name, args);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async _executeViaServer(name, args) {
|
|
283
|
+
try {
|
|
284
|
+
const serverUrl = `http://127.0.0.1:${DEFAULT_SERVER_PORT}`;
|
|
285
|
+
if (name === "read_file") {
|
|
286
|
+
const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: { "Content-Type": "application/json" },
|
|
289
|
+
body: JSON.stringify({ subAction: "read_file", path: args.path }),
|
|
290
|
+
signal: AbortSignal.timeout(10_000),
|
|
291
|
+
});
|
|
292
|
+
const data = await resp.json();
|
|
293
|
+
if (data.success) return { success: true, content: data.data?.slice(0, 10_000) ?? "" };
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
if (name === "write_file") {
|
|
297
|
+
const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: { "Content-Type": "application/json" },
|
|
300
|
+
body: JSON.stringify({ subAction: "write_file", path: args.path, content: args.content }),
|
|
301
|
+
signal: AbortSignal.timeout(10_000),
|
|
302
|
+
});
|
|
303
|
+
const data = await resp.json();
|
|
304
|
+
if (data.success) return { success: true, message: `Written to ${args.path} (via server)` };
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
if (name === "list_directory") {
|
|
308
|
+
const resp = await fetch(`${serverUrl}/api/node-filesystem-actions`, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "Content-Type": "application/json" },
|
|
311
|
+
body: JSON.stringify({ subAction: "list_dir", path: args.path || "." }),
|
|
312
|
+
signal: AbortSignal.timeout(10_000),
|
|
313
|
+
});
|
|
314
|
+
const data = await resp.json();
|
|
315
|
+
if (data.success && data.entries) {
|
|
316
|
+
const listing = data.entries.map(e => `${e.isDir ? "📁" : "📄"} ${e.name}`).join("\n");
|
|
317
|
+
return { success: true, listing };
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async _executeBuiltin(name, args) {
|
|
328
|
+
const { execFile } = await import("node:child_process");
|
|
329
|
+
const { promisify } = await import("node:util");
|
|
330
|
+
const execAsync = promisify(execFile);
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
switch (name) {
|
|
334
|
+
case "read_file": {
|
|
335
|
+
const filePath = args.path.replace(/^~/, os.homedir());
|
|
336
|
+
const content = await readFile(filePath, "utf8");
|
|
337
|
+
const truncated = content.length > 10_000
|
|
338
|
+
? content.slice(0, 10_000) + `\n\n... (truncated, ${content.length} chars total)`
|
|
339
|
+
: content;
|
|
340
|
+
return { success: true, content: truncated };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
case "write_file": {
|
|
344
|
+
const filePath = args.path.replace(/^~/, os.homedir());
|
|
345
|
+
const dir = path.dirname(filePath);
|
|
346
|
+
await mkdir(dir, { recursive: true });
|
|
347
|
+
await writeFile(filePath, args.content, "utf8");
|
|
348
|
+
return { success: true, message: `Written ${args.content.length} chars to ${filePath}` };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case "run_command": {
|
|
352
|
+
if (/security\s+find-generic-password.*-w/i.test(args.command)) {
|
|
353
|
+
return { success: false, error: "Use the 'keychain' tool instead of run_command for secrets." };
|
|
354
|
+
}
|
|
355
|
+
const { stdout, stderr } = await execAsync("/bin/bash", ["-c", args.command], {
|
|
356
|
+
timeout: 30_000,
|
|
357
|
+
maxBuffer: 1024 * 1024,
|
|
358
|
+
cwd: process.cwd(),
|
|
359
|
+
});
|
|
360
|
+
const result = (stdout + (stderr ? `\nSTDERR: ${stderr}` : "")).trim();
|
|
361
|
+
const truncated = result.length > 5_000 ? result.slice(0, 5_000) + "\n... (truncated)" : result;
|
|
362
|
+
return { success: true, output: truncated };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
case "list_directory": {
|
|
366
|
+
const { readdir } = await import("node:fs/promises");
|
|
367
|
+
const targetPath = (args.path || ".").replace(/^~/, os.homedir());
|
|
368
|
+
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
369
|
+
const list = entries.map(e => `${e.isDirectory() ? "📁" : "📄"} ${e.name}`).join("\n");
|
|
370
|
+
return { success: true, listing: list };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
case "web_search": {
|
|
374
|
+
const encoded = encodeURIComponent(args.query);
|
|
375
|
+
try {
|
|
376
|
+
const { stdout: html } = await execAsync("/usr/bin/curl", [
|
|
377
|
+
"-sL", "--max-time", "10",
|
|
378
|
+
"-H", "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
379
|
+
`https://lite.duckduckgo.com/lite/?q=${encoded}`,
|
|
380
|
+
], { timeout: 15_000 });
|
|
381
|
+
|
|
382
|
+
const snippets = [];
|
|
383
|
+
const linkRegex = /<a[^>]*class="result-link"[^>]*>(.*?)<\/a>/gs;
|
|
384
|
+
const snippetRegex = /<td class="result-snippet">(.*?)<\/td>/gs;
|
|
385
|
+
|
|
386
|
+
const links = [];
|
|
387
|
+
let m;
|
|
388
|
+
while ((m = linkRegex.exec(html)) !== null) links.push(m[1].replace(/<[^>]+>/g, "").trim());
|
|
389
|
+
const snips = [];
|
|
390
|
+
while ((m = snippetRegex.exec(html)) !== null) snips.push(m[1].replace(/<[^>]+>/g, "").trim());
|
|
391
|
+
|
|
392
|
+
for (let i = 0; i < Math.min(links.length, 5); i++) {
|
|
393
|
+
const snippet = snips[i] ? `${links[i]}\n${snips[i]}` : links[i];
|
|
394
|
+
if (snippet) snippets.push(snippet);
|
|
395
|
+
}
|
|
396
|
+
if (snippets.length > 0) return { success: true, results: snippets.join("\n\n") };
|
|
397
|
+
} catch { /* fallback */ }
|
|
398
|
+
return { success: true, results: `Search for "${args.query}" — try using run_command with curl` };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
case "file_edit": {
|
|
402
|
+
const filePath = args.path.replace(/^~/, os.homedir());
|
|
403
|
+
const content = await readFile(filePath, "utf8");
|
|
404
|
+
if (!content.includes(args.old_text)) {
|
|
405
|
+
return { success: false, error: `Text not found in ${filePath}` };
|
|
406
|
+
}
|
|
407
|
+
const newContent = content.replace(args.old_text, args.new_text);
|
|
408
|
+
await writeFile(filePath, newContent, "utf8");
|
|
409
|
+
return { success: true, message: `Edited ${filePath}` };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
case "file_search": {
|
|
413
|
+
const searchPath = (args.path || ".").replace(/^~/, os.homedir());
|
|
414
|
+
const glob = args.file_glob ? `--include="${args.file_glob}"` : "";
|
|
415
|
+
try {
|
|
416
|
+
const { stdout } = await execAsync("/bin/bash", ["-c",
|
|
417
|
+
`grep -rn ${glob} "${args.pattern}" "${searchPath}" 2>/dev/null | head -30`
|
|
418
|
+
], { timeout: 10_000 });
|
|
419
|
+
return { success: true, matches: stdout.trim().split("\n").filter(Boolean).length, results: stdout.trim().slice(0, 5000) };
|
|
420
|
+
} catch {
|
|
421
|
+
return { success: true, matches: 0, results: "No matches found." };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
case "git": {
|
|
426
|
+
try {
|
|
427
|
+
const { stdout, stderr } = await execAsync("/bin/bash", ["-c", `git ${args.command}`], {
|
|
428
|
+
timeout: 15_000, cwd: process.cwd(),
|
|
429
|
+
});
|
|
430
|
+
return { success: true, output: (stdout + (stderr ? `\n${stderr}` : "")).trim().slice(0, 5000) };
|
|
431
|
+
} catch (err) {
|
|
432
|
+
return { success: false, error: err.stderr?.trim() || err.message };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
case "web_fetch": {
|
|
437
|
+
const resp = await fetch(args.url, {
|
|
438
|
+
headers: { "User-Agent": "Wispy/0.7" },
|
|
439
|
+
signal: AbortSignal.timeout(15_000),
|
|
440
|
+
});
|
|
441
|
+
const contentType = resp.headers.get("content-type") ?? "";
|
|
442
|
+
const text = await resp.text();
|
|
443
|
+
const cleaned = contentType.includes("html")
|
|
444
|
+
? text.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
445
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
446
|
+
.replace(/<[^>]+>/g, " ")
|
|
447
|
+
.replace(/\s+/g, " ").trim()
|
|
448
|
+
: text;
|
|
449
|
+
return { success: true, content: cleaned.slice(0, 10_000), contentType, status: resp.status };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
case "keychain": {
|
|
453
|
+
const account = args.account ?? "wispy";
|
|
454
|
+
if (process.platform !== "darwin") return { success: false, error: "Keychain is only supported on macOS" };
|
|
455
|
+
|
|
456
|
+
if (args.action === "get") {
|
|
457
|
+
try {
|
|
458
|
+
const { stdout } = await execAsync("security", ["find-generic-password", "-s", args.service, "-a", account, "-w"], { timeout: 5000 });
|
|
459
|
+
const val = stdout.trim();
|
|
460
|
+
const masked = val.length > 8 ? `${val.slice(0, 4)}${"*".repeat(Math.min(val.length - 8, 20))}${val.slice(-4)}` : "****";
|
|
461
|
+
return { success: true, service: args.service, account, value_masked: masked, length: val.length };
|
|
462
|
+
} catch {
|
|
463
|
+
return { success: false, error: `No keychain entry for service="${args.service}"` };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (args.action === "set") {
|
|
467
|
+
if (!args.value) return { success: false, error: "value is required for set action" };
|
|
468
|
+
await execAsync("security", ["delete-generic-password", "-s", args.service, "-a", account]).catch(() => {});
|
|
469
|
+
await execAsync("security", ["add-generic-password", "-s", args.service, "-a", account, "-w", args.value], { timeout: 5000 });
|
|
470
|
+
return { success: true, message: `Stored secret for service="${args.service}"` };
|
|
471
|
+
}
|
|
472
|
+
if (args.action === "delete") {
|
|
473
|
+
try {
|
|
474
|
+
await execAsync("security", ["delete-generic-password", "-s", args.service, "-a", account], { timeout: 5000 });
|
|
475
|
+
return { success: true, message: `Deleted keychain entry for service="${args.service}"` };
|
|
476
|
+
} catch {
|
|
477
|
+
return { success: false, error: `No entry found for service="${args.service}"` };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (args.action === "list") {
|
|
481
|
+
try {
|
|
482
|
+
const { stdout } = await execAsync("/bin/bash", ["-c",
|
|
483
|
+
`security dump-keychain 2>/dev/null | grep -A 4 '"svce"' | grep -E "svce|acct" | head -20`
|
|
484
|
+
], { timeout: 5000 });
|
|
485
|
+
return { success: true, entries: stdout.trim() || "No entries found" };
|
|
486
|
+
} catch {
|
|
487
|
+
return { success: true, entries: "No entries found" };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { success: false, error: "action must be get, set, delete, or list" };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
case "clipboard": {
|
|
494
|
+
if (args.action === "copy") {
|
|
495
|
+
const { exec: execCb } = await import("node:child_process");
|
|
496
|
+
const execP = promisify(execCb);
|
|
497
|
+
const copyCmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard";
|
|
498
|
+
await execP(`echo "${args.text.replace(/"/g, '\\"')}" | ${copyCmd}`);
|
|
499
|
+
return { success: true, message: `Copied ${args.text.length} chars to clipboard` };
|
|
500
|
+
}
|
|
501
|
+
if (args.action === "paste") {
|
|
502
|
+
const pasteCmd = process.platform === "darwin" ? "pbpaste" : "xclip -selection clipboard -o";
|
|
503
|
+
const { stdout } = await execAsync("/bin/bash", ["-c", pasteCmd], { timeout: 3000 });
|
|
504
|
+
return { success: true, content: stdout.slice(0, 5000) };
|
|
505
|
+
}
|
|
506
|
+
return { success: false, error: "action must be 'copy' or 'paste'" };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Agent tools — these are handled by the engine level
|
|
510
|
+
case "spawn_agent":
|
|
511
|
+
case "list_agents":
|
|
512
|
+
case "get_agent_result":
|
|
513
|
+
case "update_plan":
|
|
514
|
+
case "pipeline":
|
|
515
|
+
case "spawn_async_agent":
|
|
516
|
+
case "ralph_loop":
|
|
517
|
+
return { success: false, error: `Tool "${name}" requires engine context. Call via WispyEngine.processMessage().` };
|
|
518
|
+
|
|
519
|
+
default:
|
|
520
|
+
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` };
|
|
521
|
+
}
|
|
522
|
+
} catch (err) {
|
|
523
|
+
return { success: false, error: err.message };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|