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.
@@ -15,6 +15,8 @@ import os from "node:os";
15
15
  import path from "node:path";
16
16
  import { createInterface } from "node:readline";
17
17
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { fileURLToPath as _fileURLToPath } from "node:url";
19
+ import { MCPManager, ensureDefaultMcpConfig } from "./mcp-client.mjs";
18
20
 
19
21
  // ---------------------------------------------------------------------------
20
22
  // Config
@@ -22,6 +24,10 @@ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
22
24
 
23
25
  const WISPY_DIR = path.join(os.homedir(), ".wispy");
24
26
  const MEMORY_DIR = path.join(WISPY_DIR, "memory");
27
+ const MCP_CONFIG_PATH = path.join(WISPY_DIR, "mcp.json");
28
+
29
+ // Global MCP manager — initialized at startup
30
+ const mcpManager = new MCPManager(MCP_CONFIG_PATH);
25
31
 
26
32
  // Workstream-aware conversation storage
27
33
  // wispy -w "project-name" → separate conversation per workstream
@@ -619,6 +625,11 @@ function optimizeContext(messages, maxTokens = 30_000) {
619
625
  // Tool definitions (Gemini function calling format)
620
626
  // ---------------------------------------------------------------------------
621
627
 
628
+ // Returns merged static + dynamically registered MCP tool definitions
629
+ function getAllToolDefinitions() {
630
+ return [...TOOL_DEFINITIONS, ...mcpManager.getToolDefinitions()];
631
+ }
632
+
622
633
  const TOOL_DEFINITIONS = [
623
634
  {
624
635
  name: "read_file",
@@ -1464,8 +1475,36 @@ Be concise. Your output feeds into the next stage.`;
1464
1475
  return { success: true, iterations: MAX_ITERATIONS, result: lastResult, verified: false, message: "Max iterations reached" };
1465
1476
  }
1466
1477
 
1467
- default:
1468
- return { success: false, error: `Unknown tool: ${name}` };
1478
+ default: {
1479
+ // Check MCP tools first
1480
+ if (mcpManager.hasTool(name)) {
1481
+ try {
1482
+ const result = await mcpManager.callTool(name, args);
1483
+ // MCP tools/call returns { content: [{type, text}], isError? }
1484
+ if (result?.isError) {
1485
+ const errText = result.content?.map(c => c.text ?? "").join("") ?? "MCP tool error";
1486
+ return { success: false, error: errText };
1487
+ }
1488
+ const output = result?.content?.map(c => c.text ?? c.data ?? JSON.stringify(c)).join("\n") ?? JSON.stringify(result);
1489
+ return { success: true, output };
1490
+ } catch (err) {
1491
+ return { success: false, error: `MCP tool error: ${err.message}` };
1492
+ }
1493
+ }
1494
+
1495
+ // Unknown tool — try to execute as a skill via run_command
1496
+ // This handles cases where the AI hallucinates tools from skill descriptions
1497
+ const skills = await loadSkills();
1498
+ const matchedSkill = skills.find(s => s.name.toLowerCase() === name.toLowerCase());
1499
+ if (matchedSkill) {
1500
+ return {
1501
+ success: false,
1502
+ error: `"${name}" is a skill, not a tool. Use run_command to execute commands from the ${name} skill guide. Example from the skill: look for curl/bash commands in the skill description.`,
1503
+ skill_hint: matchedSkill.body.slice(0, 500),
1504
+ };
1505
+ }
1506
+ return { success: false, error: `Unknown tool: ${name}. Available: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result, and MCP tools (see /mcp list)` };
1507
+ }
1469
1508
  }
1470
1509
  } catch (err) {
1471
1510
  return { success: false, error: err.message };
@@ -1493,6 +1532,69 @@ async function loadWorkMd() {
1493
1532
  return null;
1494
1533
  }
1495
1534
 
1535
+ // ---------------------------------------------------------------------------
1536
+ // Skill loader — loads SKILL.md files from multiple sources
1537
+ // Compatible with OpenClaw and Claude Code skill formats
1538
+ // ---------------------------------------------------------------------------
1539
+
1540
+ async function loadSkills() {
1541
+ const skillDirs = [
1542
+ // OpenClaw built-in skills
1543
+ "/opt/homebrew/lib/node_modules/openclaw/skills",
1544
+ // OpenClaw user skills
1545
+ path.join(os.homedir(), ".openclaw", "workspace", "skills"),
1546
+ // Wispy skills
1547
+ path.join(WISPY_DIR, "skills"),
1548
+ // Project-local skills
1549
+ path.resolve(".wispy", "skills"),
1550
+ // Claude Code skills (if installed)
1551
+ path.join(os.homedir(), ".claude", "skills"),
1552
+ ];
1553
+
1554
+ const skills = [];
1555
+ const { readdir: rd, stat: st } = await import("node:fs/promises");
1556
+
1557
+ for (const dir of skillDirs) {
1558
+ try {
1559
+ const entries = await rd(dir);
1560
+ for (const entry of entries) {
1561
+ const skillMdPath = path.join(dir, entry, "SKILL.md");
1562
+ try {
1563
+ const content = await readFile(skillMdPath, "utf8");
1564
+ // Parse frontmatter
1565
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1566
+ let name = entry;
1567
+ let description = "";
1568
+ let body = content;
1569
+
1570
+ if (fmMatch) {
1571
+ const fm = fmMatch[1];
1572
+ body = fmMatch[2];
1573
+ const nameMatch = fm.match(/name:\s*["']?(.+?)["']?\s*$/m);
1574
+ const descMatch = fm.match(/description:\s*["'](.+?)["']\s*$/m);
1575
+ if (nameMatch) name = nameMatch[1].trim();
1576
+ if (descMatch) description = descMatch[1].trim();
1577
+ }
1578
+
1579
+ skills.push({ name, description, body: body.trim(), path: skillMdPath, source: dir });
1580
+ } catch { /* no SKILL.md */ }
1581
+ }
1582
+ } catch { /* dir doesn't exist */ }
1583
+ }
1584
+
1585
+ return skills;
1586
+ }
1587
+
1588
+ function matchSkills(prompt, skills) {
1589
+ const lower = prompt.toLowerCase();
1590
+ return skills.filter(skill => {
1591
+ const nameMatch = lower.includes(skill.name.toLowerCase());
1592
+ const descWords = skill.description.toLowerCase().split(/\s+/);
1593
+ const descMatch = descWords.some(w => w.length > 4 && lower.includes(w));
1594
+ return nameMatch || descMatch;
1595
+ });
1596
+ }
1597
+
1496
1598
  async function buildSystemPrompt(messages = []) {
1497
1599
  // Detect user's language from last message for system prompt hint
1498
1600
  const lastUserMsg = messages?.find ? [...messages].reverse().find(m => m.role === "user")?.content ?? "" : "";
@@ -1524,7 +1626,7 @@ async function buildSystemPrompt(messages = []) {
1524
1626
  " - NEVER reply in Korean when the user wrote in English.",
1525
1627
  "",
1526
1628
  "## Tools",
1527
- "You have 18 tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result.",
1629
+ `You have ${18 + mcpManager.getAllTools().length} tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result${mcpManager.getAllTools().length > 0 ? ", and MCP tools: " + mcpManager.getAllTools().map(t => t.wispyName).join(", ") : ""}.`,
1528
1630
  "- file_edit: for targeted text replacement (prefer over write_file for edits)",
1529
1631
  "- file_search: grep across codebase",
1530
1632
  "- git: any git command",
@@ -1558,6 +1660,25 @@ async function buildSystemPrompt(messages = []) {
1558
1660
  parts.push("");
1559
1661
  }
1560
1662
 
1663
+ // Load and inject matching skills
1664
+ const allSkills = await loadSkills();
1665
+ if (allSkills.length > 0 && lastUserMsg) {
1666
+ const matched = matchSkills(lastUserMsg, allSkills);
1667
+ if (matched.length > 0) {
1668
+ parts.push("## Active Skills (instructions — use run_command/web_fetch to execute)");
1669
+ parts.push("Skills are NOT tools — they are guides. Use run_command to execute the commands described in them.");
1670
+ for (const skill of matched.slice(0, 3)) { // Max 3 skills per turn
1671
+ parts.push(`### ${skill.name}`);
1672
+ parts.push(skill.body.slice(0, 5000));
1673
+ parts.push("");
1674
+ }
1675
+ }
1676
+ // Always list available skills
1677
+ parts.push(`## Available Skills (${allSkills.length} installed)`);
1678
+ parts.push(allSkills.map(s => `- ${s.name}: ${s.description.slice(0, 60)}`).join("\n"));
1679
+ parts.push("");
1680
+ }
1681
+
1561
1682
  return parts.join("\n");
1562
1683
  }
1563
1684
 
@@ -1617,7 +1738,7 @@ async function chatGeminiWithTools(messages, onChunk) {
1617
1738
  sessionTokens.input += estimateTokens(systemInstruction + inputText);
1618
1739
 
1619
1740
  const geminiTools = [{
1620
- functionDeclarations: TOOL_DEFINITIONS.map(t => ({
1741
+ functionDeclarations: getAllToolDefinitions().map(t => ({
1621
1742
  name: t.name,
1622
1743
  description: t.description,
1623
1744
  parameters: t.parameters,
@@ -1707,7 +1828,7 @@ async function chatOpenAIWithTools(messages, onChunk) {
1707
1828
  return { role: m.role === "assistant" ? "assistant" : m.role, content: m.content };
1708
1829
  });
1709
1830
 
1710
- const openaiTools = TOOL_DEFINITIONS.map(t => ({
1831
+ const openaiTools = getAllToolDefinitions().map(t => ({
1711
1832
  type: "function",
1712
1833
  function: { name: t.name, description: t.description, parameters: t.parameters },
1713
1834
  }));
@@ -1785,7 +1906,7 @@ async function chatAnthropicWithTools(messages, onChunk) {
1785
1906
  const inputText = anthropicMessages.map(m => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join("");
1786
1907
  sessionTokens.input += estimateTokens(systemPrompt + inputText);
1787
1908
 
1788
- const anthropicTools = TOOL_DEFINITIONS.map(t => ({
1909
+ const anthropicTools = getAllToolDefinitions().map(t => ({
1789
1910
  name: t.name,
1790
1911
  description: t.description,
1791
1912
  input_schema: t.parameters,
@@ -2131,6 +2252,30 @@ ${bold("Wispy Commands:")}
2131
2252
  return true;
2132
2253
  }
2133
2254
 
2255
+ if (cmd === "/skills") {
2256
+ const skills = await loadSkills();
2257
+ if (skills.length === 0) {
2258
+ console.log(dim("No skills installed."));
2259
+ console.log(dim("Add skills to ~/.wispy/skills/ or install OpenClaw skills."));
2260
+ } else {
2261
+ console.log(bold(`\n🧩 Skills (${skills.length} installed):\n`));
2262
+ const bySource = {};
2263
+ for (const s of skills) {
2264
+ const src = s.source.includes("openclaw") ? "OpenClaw" : s.source.includes(".wispy") ? "Wispy" : s.source.includes(".claude") ? "Claude" : "Project";
2265
+ if (!bySource[src]) bySource[src] = [];
2266
+ bySource[src].push(s);
2267
+ }
2268
+ for (const [src, sks] of Object.entries(bySource)) {
2269
+ console.log(` ${bold(src)} (${sks.length}):`);
2270
+ for (const s of sks) {
2271
+ console.log(` ${green(s.name.padEnd(20))} ${dim(s.description.slice(0, 50))}`);
2272
+ }
2273
+ console.log("");
2274
+ }
2275
+ }
2276
+ return true;
2277
+ }
2278
+
2134
2279
  if (cmd === "/sessions" || cmd === "/ls") {
2135
2280
  const wsList = await listWorkstreams();
2136
2281
  if (wsList.length === 0) {
@@ -2203,6 +2348,119 @@ ${bold("Wispy Commands:")}
2203
2348
  process.exit(0);
2204
2349
  }
2205
2350
 
2351
+ // ---------------------------------------------------------------------------
2352
+ // /mcp — MCP server management
2353
+ // ---------------------------------------------------------------------------
2354
+ if (cmd === "/mcp") {
2355
+ const sub = parts[1] ?? "list";
2356
+
2357
+ if (sub === "list") {
2358
+ const status = mcpManager.getStatus();
2359
+ const allTools = mcpManager.getAllTools();
2360
+ if (status.length === 0) {
2361
+ console.log(dim("No MCP servers connected."));
2362
+ console.log(dim(`Config: ${MCP_CONFIG_PATH}`));
2363
+ console.log(dim("Use /mcp connect <name> to connect a server."));
2364
+ } else {
2365
+ console.log(bold(`\n🔌 MCP Servers (${status.length}):\n`));
2366
+ for (const s of status) {
2367
+ const icon = s.connected ? green("●") : red("○");
2368
+ const toolList = s.tools.length > 0
2369
+ ? dim(` [${s.tools.slice(0, 5).join(", ")}${s.tools.length > 5 ? "..." : ""}]`)
2370
+ : "";
2371
+ console.log(` ${icon} ${bold(s.name.padEnd(18))} ${s.toolCount} tools${toolList}`);
2372
+ if (s.serverInfo?.name) console.log(dim(` server: ${s.serverInfo.name} v${s.serverInfo.version ?? "?"}`));
2373
+ }
2374
+ }
2375
+ if (allTools.length > 0) {
2376
+ console.log(bold(`\n🧰 MCP Tools (${allTools.length} total):\n`));
2377
+ for (const t of allTools) {
2378
+ console.log(` ${cyan(t.wispyName.padEnd(30))} ${dim(t.description.slice(0, 60))}`);
2379
+ }
2380
+ }
2381
+ console.log("");
2382
+ return true;
2383
+ }
2384
+
2385
+ if (sub === "connect") {
2386
+ const serverName = parts[2];
2387
+ if (!serverName) {
2388
+ console.log(yellow("Usage: /mcp connect <server-name>"));
2389
+ return true;
2390
+ }
2391
+ const config = await mcpManager.loadConfig();
2392
+ const serverConfig = config.mcpServers?.[serverName];
2393
+ if (!serverConfig) {
2394
+ console.log(red(`Server "${serverName}" not found in ${MCP_CONFIG_PATH}`));
2395
+ console.log(dim(`Available: ${Object.keys(config.mcpServers ?? {}).join(", ") || "none"}`));
2396
+ return true;
2397
+ }
2398
+ process.stdout.write(dim(` Connecting to "${serverName}"...`));
2399
+ try {
2400
+ const client = await mcpManager.connect(serverName, serverConfig);
2401
+ console.log(green(` ✓ connected (${client.tools.length} tools)`));
2402
+ if (client.tools.length > 0) {
2403
+ console.log(dim(` Tools: ${client.tools.map(t => t.name).join(", ")}`));
2404
+ }
2405
+ } catch (err) {
2406
+ console.log(red(` ✗ failed: ${err.message.slice(0, 120)}`));
2407
+ }
2408
+ return true;
2409
+ }
2410
+
2411
+ if (sub === "disconnect") {
2412
+ const serverName = parts[2];
2413
+ if (!serverName) {
2414
+ console.log(yellow("Usage: /mcp disconnect <server-name>"));
2415
+ return true;
2416
+ }
2417
+ const ok = mcpManager.disconnect(serverName);
2418
+ console.log(ok ? green(`✓ Disconnected "${serverName}"`) : yellow(`"${serverName}" was not connected`));
2419
+ return true;
2420
+ }
2421
+
2422
+ if (sub === "config") {
2423
+ console.log(dim(`Config file: ${MCP_CONFIG_PATH}`));
2424
+ try {
2425
+ const cfg = await mcpManager.loadConfig();
2426
+ const servers = cfg.mcpServers ?? {};
2427
+ console.log(bold(`\nDefined servers (${Object.keys(servers).length}):\n`));
2428
+ for (const [name, s] of Object.entries(servers)) {
2429
+ const status = s.disabled ? dim("disabled") : green("enabled");
2430
+ console.log(` ${name.padEnd(20)} ${status}`);
2431
+ console.log(dim(` cmd: ${s.command} ${(s.args ?? []).join(" ")}`));
2432
+ }
2433
+ } catch {
2434
+ console.log(dim("No config found."));
2435
+ }
2436
+ return true;
2437
+ }
2438
+
2439
+ if (sub === "reload") {
2440
+ console.log(dim("Reloading MCP servers..."));
2441
+ mcpManager.disconnectAll();
2442
+ const results = await mcpManager.autoConnect();
2443
+ for (const r of results) {
2444
+ if (r.status === "connected") console.log(green(` ✓ ${r.name} (${r.tools} tools)`));
2445
+ else if (r.status === "disabled") console.log(dim(` ○ ${r.name} (disabled)`));
2446
+ else console.log(red(` ✗ ${r.name}: ${r.error?.slice(0, 80)}`));
2447
+ }
2448
+ return true;
2449
+ }
2450
+
2451
+ // Unknown subcommand
2452
+ console.log(`
2453
+ ${bold("/mcp commands:")}
2454
+ ${cyan("/mcp list")} List connected servers and tools
2455
+ ${cyan("/mcp connect <name>")} Connect a server from config
2456
+ ${cyan("/mcp disconnect <name>")} Disconnect a server
2457
+ ${cyan("/mcp config")} Show mcp.json config
2458
+ ${cyan("/mcp reload")} Reconnect all servers
2459
+ ${dim(`Config: ${MCP_CONFIG_PATH}`)}
2460
+ `);
2461
+ return true;
2462
+ }
2463
+
2206
2464
  return false;
2207
2465
  }
2208
2466
 
@@ -2500,6 +2758,28 @@ if (!API_KEY && PROVIDER !== "ollama") {
2500
2758
  }
2501
2759
  }
2502
2760
 
2761
+ // Auto-connect MCP servers from ~/.wispy/mcp.json
2762
+ await ensureDefaultMcpConfig(MCP_CONFIG_PATH);
2763
+ {
2764
+ const mcpResults = await mcpManager.autoConnect();
2765
+ const connected = mcpResults.filter(r => r.status === "connected");
2766
+ const failed = mcpResults.filter(r => r.status === "failed");
2767
+ if (connected.length > 0) {
2768
+ // Quiet success — only show if verbose
2769
+ // console.log(dim(`🔌 MCP: ${connected.map(r => `${r.name}(${r.tools})`).join(", ")}`));
2770
+ }
2771
+ if (failed.length > 0 && process.env.WISPY_DEBUG) {
2772
+ for (const r of failed) {
2773
+ console.error(dim(`⚠ MCP "${r.name}" failed: ${r.error?.slice(0, 80)}`));
2774
+ }
2775
+ }
2776
+ }
2777
+
2778
+ // Graceful MCP cleanup on exit
2779
+ process.on("exit", () => { try { mcpManager.disconnectAll(); } catch {} });
2780
+ process.on("SIGINT", () => { mcpManager.disconnectAll(); process.exit(0); });
2781
+ process.on("SIGTERM", () => { mcpManager.disconnectAll(); process.exit(0); });
2782
+
2503
2783
  // Auto-start server before entering REPL or one-shot
2504
2784
  const serverStatus = await startServerIfNeeded();
2505
2785
  if (serverStatus.started) {
@@ -2585,6 +2865,7 @@ ${bold("In-session commands:")}
2585
2865
  /workstreams List all workstreams
2586
2866
  /overview Director view — all workstreams at a glance
2587
2867
  /search <keyword> Search across all workstreams
2868
+ /skills List installed skills (OpenClaw/Claude compatible)
2588
2869
  /sessions List all sessions
2589
2870
  /delete <name> Delete a session
2590
2871
  /export [md|clipboard] Export conversation