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.
@@ -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
- // Wispy CLI entry point — delegates to the main REPL
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
- // Dynamic import of the main module
11
- await import(mainScript);
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
+ }