wispy-cli 0.3.1 → 0.4.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/bin/wispy-tui.mjs +14 -0
- package/bin/wispy.mjs +22 -4
- package/lib/mcp-client.mjs +381 -0
- package/lib/wispy-repl.mjs +287 -6
- package/lib/wispy-tui.mjs +812 -0
- package/package.json +12 -3
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* wispy-tui — Ink-based TUI entry point
|
|
5
|
+
* Delegates to lib/wispy-tui.mjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const tuiScript = path.join(__dirname, "..", "lib", "wispy-tui.mjs");
|
|
13
|
+
|
|
14
|
+
await import(tuiScript);
|
package/bin/wispy.mjs
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Wispy CLI entry point
|
|
5
|
+
*
|
|
6
|
+
* Flags:
|
|
7
|
+
* --tui Launch Ink-based TUI mode
|
|
8
|
+
* (default) Launch interactive REPL
|
|
9
|
+
*/
|
|
10
|
+
|
|
4
11
|
import { fileURLToPath } from "node:url";
|
|
5
12
|
import path from "node:path";
|
|
6
13
|
|
|
7
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const mainScript = path.join(__dirname, "..", "lib", "wispy-repl.mjs");
|
|
9
15
|
|
|
10
|
-
//
|
|
11
|
-
|
|
16
|
+
// Check for --tui flag
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const tuiMode = args.includes("--tui");
|
|
19
|
+
|
|
20
|
+
if (tuiMode) {
|
|
21
|
+
// Remove --tui from args so wispy-tui doesn't see it
|
|
22
|
+
const newArgs = args.filter(a => a !== "--tui");
|
|
23
|
+
process.argv = [process.argv[0], process.argv[1], ...newArgs];
|
|
24
|
+
const tuiScript = path.join(__dirname, "..", "lib", "wispy-tui.mjs");
|
|
25
|
+
await import(tuiScript);
|
|
26
|
+
} else {
|
|
27
|
+
const mainScript = path.join(__dirname, "..", "lib", "wispy-repl.mjs");
|
|
28
|
+
await import(mainScript);
|
|
29
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mcp-client.mjs — MCP (Model Context Protocol) client for Wispy
|
|
4
|
+
*
|
|
5
|
+
* Implements:
|
|
6
|
+
* - stdio transport (spawn server process, communicate over stdin/stdout)
|
|
7
|
+
* - JSON-RPC 2.0 framing (newline-delimited)
|
|
8
|
+
* - initialize / tools/list / tools/call
|
|
9
|
+
* - MCPManager: manages multiple named server connections
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
import readline from "node:readline";
|
|
15
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// MCPClient — single server connection
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export class MCPClient extends EventEmitter {
|
|
24
|
+
constructor(name, config) {
|
|
25
|
+
super();
|
|
26
|
+
this.name = name;
|
|
27
|
+
this.config = config; // { command, args?, env?, cwd? }
|
|
28
|
+
this.process = null;
|
|
29
|
+
this.rl = null;
|
|
30
|
+
this._requestId = 0;
|
|
31
|
+
this._pending = new Map(); // id → { resolve, reject, timer }
|
|
32
|
+
this.tools = [];
|
|
33
|
+
this.connected = false;
|
|
34
|
+
this.initialized = false;
|
|
35
|
+
this.serverInfo = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async connect() {
|
|
39
|
+
const { command, args = [], env = {}, cwd } = this.config;
|
|
40
|
+
|
|
41
|
+
this.process = spawn(command, args, {
|
|
42
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
+
env: { ...process.env, ...env },
|
|
44
|
+
cwd: cwd ?? undefined,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
this.process.on("error", (err) => {
|
|
48
|
+
this.connected = false;
|
|
49
|
+
this.emit("error", err);
|
|
50
|
+
this._rejectAll(err);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.process.on("exit", (code) => {
|
|
54
|
+
this.connected = false;
|
|
55
|
+
this.initialized = false;
|
|
56
|
+
this.emit("disconnect", code);
|
|
57
|
+
this._rejectAll(new Error(`MCP server "${this.name}" exited with code ${code}`));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Read JSON-RPC messages from stdout (newline-delimited)
|
|
61
|
+
this.rl = readline.createInterface({
|
|
62
|
+
input: this.process.stdout,
|
|
63
|
+
crlfDelay: Infinity,
|
|
64
|
+
});
|
|
65
|
+
this.rl.on("line", (line) => this._handleLine(line.trim()));
|
|
66
|
+
|
|
67
|
+
// Collect stderr for debugging
|
|
68
|
+
this._stderrBuf = "";
|
|
69
|
+
this.process.stderr.on("data", (data) => {
|
|
70
|
+
this._stderrBuf += data.toString();
|
|
71
|
+
this.emit("stderr", data.toString());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
this.connected = true;
|
|
75
|
+
|
|
76
|
+
// Run MCP handshake
|
|
77
|
+
await this._initialize();
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async _initialize() {
|
|
82
|
+
const result = await this._request("initialize", {
|
|
83
|
+
protocolVersion: "2024-11-05",
|
|
84
|
+
capabilities: {
|
|
85
|
+
roots: { listChanged: false },
|
|
86
|
+
sampling: {},
|
|
87
|
+
},
|
|
88
|
+
clientInfo: {
|
|
89
|
+
name: "wispy",
|
|
90
|
+
version: "0.5.0",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.serverInfo = result?.serverInfo ?? null;
|
|
95
|
+
|
|
96
|
+
// Send initialized notification (required by spec)
|
|
97
|
+
this._notify("notifications/initialized", {});
|
|
98
|
+
this.initialized = true;
|
|
99
|
+
this.emit("ready", result);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async listTools() {
|
|
104
|
+
const result = await this._request("tools/list", {});
|
|
105
|
+
this.tools = result?.tools ?? [];
|
|
106
|
+
return this.tools;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async callTool(name, args = {}) {
|
|
110
|
+
const result = await this._request("tools/call", {
|
|
111
|
+
name,
|
|
112
|
+
arguments: args,
|
|
113
|
+
});
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
disconnect() {
|
|
118
|
+
if (this.rl) {
|
|
119
|
+
this.rl.close();
|
|
120
|
+
this.rl = null;
|
|
121
|
+
}
|
|
122
|
+
if (this.process) {
|
|
123
|
+
try { this.process.stdin.end(); } catch {}
|
|
124
|
+
try { this.process.kill("SIGTERM"); } catch {}
|
|
125
|
+
this.process = null;
|
|
126
|
+
}
|
|
127
|
+
this.connected = false;
|
|
128
|
+
this.initialized = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Internal JSON-RPC helpers
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
_handleLine(line) {
|
|
136
|
+
if (!line) return;
|
|
137
|
+
try {
|
|
138
|
+
const msg = JSON.parse(line);
|
|
139
|
+
// Response to a pending request
|
|
140
|
+
if (msg.id !== undefined && this._pending.has(msg.id)) {
|
|
141
|
+
const { resolve, reject } = this._pending.get(msg.id);
|
|
142
|
+
this._pending.delete(msg.id);
|
|
143
|
+
if (msg.error) {
|
|
144
|
+
reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
|
|
145
|
+
} else {
|
|
146
|
+
resolve(msg.result);
|
|
147
|
+
}
|
|
148
|
+
} else if (msg.method) {
|
|
149
|
+
// Server-initiated notification or request
|
|
150
|
+
this.emit("notification", msg);
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Non-JSON line (server logs etc.) — ignore
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_request(method, params, timeoutMs = 30_000) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
if (!this.connected || !this.process) {
|
|
160
|
+
return reject(new Error(`MCP server "${this.name}" is not connected`));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const id = ++this._requestId;
|
|
164
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
165
|
+
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
this._pending.delete(id);
|
|
168
|
+
reject(new Error(`MCP request "${method}" timed out after ${timeoutMs}ms`));
|
|
169
|
+
}, timeoutMs);
|
|
170
|
+
|
|
171
|
+
this._pending.set(id, {
|
|
172
|
+
resolve: (val) => { clearTimeout(timer); resolve(val); },
|
|
173
|
+
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
this.process.stdin.write(msg + "\n");
|
|
178
|
+
} catch (err) {
|
|
179
|
+
this._pending.delete(id);
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
reject(err);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_notify(method, params) {
|
|
187
|
+
if (!this.connected || !this.process) return;
|
|
188
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
189
|
+
try {
|
|
190
|
+
this.process.stdin.write(msg + "\n");
|
|
191
|
+
} catch { /* ignore */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_rejectAll(err) {
|
|
195
|
+
for (const { reject } of this._pending.values()) {
|
|
196
|
+
reject(err);
|
|
197
|
+
}
|
|
198
|
+
this._pending.clear();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// MCPManager — manages multiple named server connections
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
export class MCPManager {
|
|
207
|
+
constructor(configPath) {
|
|
208
|
+
this.configPath = configPath;
|
|
209
|
+
this.clients = new Map(); // name → MCPClient
|
|
210
|
+
this._toolIndex = new Map(); // wispyName → { serverName, mcpName, inputSchema, description }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Load ~/.wispy/mcp.json (creates default if missing)
|
|
214
|
+
async loadConfig() {
|
|
215
|
+
try {
|
|
216
|
+
const raw = await readFile(this.configPath, "utf8");
|
|
217
|
+
return JSON.parse(raw);
|
|
218
|
+
} catch {
|
|
219
|
+
return { mcpServers: {} };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Save updated config
|
|
224
|
+
async saveConfig(config) {
|
|
225
|
+
await mkdir(path.dirname(this.configPath), { recursive: true });
|
|
226
|
+
await writeFile(this.configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Auto-connect all non-disabled servers from config
|
|
230
|
+
async autoConnect() {
|
|
231
|
+
const config = await this.loadConfig();
|
|
232
|
+
const servers = config.mcpServers ?? {};
|
|
233
|
+
const results = [];
|
|
234
|
+
|
|
235
|
+
for (const [name, serverConfig] of Object.entries(servers)) {
|
|
236
|
+
if (serverConfig.disabled) {
|
|
237
|
+
results.push({ name, status: "disabled" });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const client = await this.connect(name, serverConfig);
|
|
242
|
+
results.push({ name, status: "connected", tools: client.tools.length });
|
|
243
|
+
} catch (err) {
|
|
244
|
+
results.push({ name, status: "failed", error: err.message.slice(0, 200) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return results;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Connect a single server by name + config
|
|
252
|
+
async connect(name, serverConfig) {
|
|
253
|
+
// Disconnect existing connection if any
|
|
254
|
+
if (this.clients.has(name)) {
|
|
255
|
+
this.clients.get(name).disconnect();
|
|
256
|
+
this.clients.delete(name);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const client = new MCPClient(name, serverConfig);
|
|
260
|
+
await client.connect();
|
|
261
|
+
await client.listTools();
|
|
262
|
+
|
|
263
|
+
this.clients.set(name, client);
|
|
264
|
+
this._rebuildToolIndex();
|
|
265
|
+
|
|
266
|
+
return client;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Disconnect a single server by name
|
|
270
|
+
disconnect(name) {
|
|
271
|
+
const client = this.clients.get(name);
|
|
272
|
+
if (client) {
|
|
273
|
+
client.disconnect();
|
|
274
|
+
this.clients.delete(name);
|
|
275
|
+
this._rebuildToolIndex();
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Disconnect all servers
|
|
282
|
+
disconnectAll() {
|
|
283
|
+
for (const client of this.clients.values()) {
|
|
284
|
+
try { client.disconnect(); } catch {}
|
|
285
|
+
}
|
|
286
|
+
this.clients.clear();
|
|
287
|
+
this._toolIndex.clear();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Rebuild the flat tool index after connections change
|
|
291
|
+
_rebuildToolIndex() {
|
|
292
|
+
this._toolIndex.clear();
|
|
293
|
+
for (const [serverName, client] of this.clients.entries()) {
|
|
294
|
+
for (const tool of client.tools) {
|
|
295
|
+
// Prefix to avoid name collisions: mcp_<server>_<tool>
|
|
296
|
+
const wispyName = `mcp_${serverName}_${tool.name}`.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
297
|
+
this._toolIndex.set(wispyName, {
|
|
298
|
+
wispyName,
|
|
299
|
+
serverName,
|
|
300
|
+
mcpName: tool.name,
|
|
301
|
+
description: `[MCP:${serverName}] ${tool.description ?? tool.name}`,
|
|
302
|
+
inputSchema: tool.inputSchema ?? { type: "object", properties: {}, required: [] },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Execute an MCP tool call by its wispy name
|
|
309
|
+
async callTool(wispyName, args) {
|
|
310
|
+
const toolInfo = this._toolIndex.get(wispyName);
|
|
311
|
+
if (!toolInfo) throw new Error(`MCP tool not found: ${wispyName}`);
|
|
312
|
+
|
|
313
|
+
const client = this.clients.get(toolInfo.serverName);
|
|
314
|
+
if (!client?.connected) throw new Error(`MCP server not connected: ${toolInfo.serverName}`);
|
|
315
|
+
|
|
316
|
+
return client.callTool(toolInfo.mcpName, args);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Returns tool definitions in Wispy TOOL_DEFINITIONS format
|
|
320
|
+
getToolDefinitions() {
|
|
321
|
+
return Array.from(this._toolIndex.values()).map(t => ({
|
|
322
|
+
name: t.wispyName,
|
|
323
|
+
description: t.description,
|
|
324
|
+
parameters: t.inputSchema,
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Check if a tool name is an MCP tool
|
|
329
|
+
hasTool(wispyName) {
|
|
330
|
+
return this._toolIndex.has(wispyName);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Get all tools as a flat array
|
|
334
|
+
getAllTools() {
|
|
335
|
+
return Array.from(this._toolIndex.values());
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Get connection status for all known servers
|
|
339
|
+
getStatus() {
|
|
340
|
+
return Array.from(this.clients.entries()).map(([name, client]) => ({
|
|
341
|
+
name,
|
|
342
|
+
connected: client.connected,
|
|
343
|
+
initialized: client.initialized,
|
|
344
|
+
toolCount: client.tools.length,
|
|
345
|
+
tools: client.tools.map(t => t.name),
|
|
346
|
+
serverInfo: client.serverInfo,
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Default MCP config writer (call once at first run)
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
export async function ensureDefaultMcpConfig(configPath) {
|
|
356
|
+
try {
|
|
357
|
+
await readFile(configPath, "utf8");
|
|
358
|
+
// Already exists — don't overwrite
|
|
359
|
+
} catch {
|
|
360
|
+
const defaultConfig = {
|
|
361
|
+
mcpServers: {
|
|
362
|
+
// Example: filesystem server (disabled by default)
|
|
363
|
+
filesystem: {
|
|
364
|
+
command: "npx",
|
|
365
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", os.homedir()],
|
|
366
|
+
disabled: true,
|
|
367
|
+
description: "MCP filesystem server — access files via MCP protocol",
|
|
368
|
+
},
|
|
369
|
+
// Example: fetch server (disabled by default)
|
|
370
|
+
fetch: {
|
|
371
|
+
command: "npx",
|
|
372
|
+
args: ["-y", "@modelcontextprotocol/server-fetch"],
|
|
373
|
+
disabled: true,
|
|
374
|
+
description: "MCP fetch server — HTTP fetch via MCP protocol",
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
379
|
+
await writeFile(configPath, JSON.stringify(defaultConfig, null, 2) + "\n", "utf8");
|
|
380
|
+
}
|
|
381
|
+
}
|