wispy-cli 0.3.0 → 0.3.2

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.
Files changed (2) hide show
  1. package/lib/wispy-repl.mjs +209 -3
  2. package/package.json +1 -1
@@ -724,6 +724,20 @@ const TOOL_DEFINITIONS = [
724
724
  required: ["url"],
725
725
  },
726
726
  },
727
+ {
728
+ name: "keychain",
729
+ description: "Manage macOS Keychain secrets. Read (masked), store, or delete credentials. Values are never shown in full — only first 4 + last 4 chars.",
730
+ parameters: {
731
+ type: "object",
732
+ properties: {
733
+ action: { type: "string", enum: ["get", "set", "delete", "list"], description: "get: read secret (masked), set: store secret, delete: remove, list: search" },
734
+ service: { type: "string", description: "Service name (e.g., 'google-ai-key', 'my-api-token')" },
735
+ account: { type: "string", description: "Account name (default: 'wispy')" },
736
+ value: { type: "string", description: "Secret value (only for 'set' action)" },
737
+ },
738
+ required: ["action", "service"],
739
+ },
740
+ },
727
741
  {
728
742
  name: "clipboard",
729
743
  description: "Copy text to clipboard (macOS/Linux) or read current clipboard contents.",
@@ -923,6 +937,10 @@ async function executeTool(name, args) {
923
937
  }
924
938
 
925
939
  case "run_command": {
940
+ // Block direct keychain password reads via run_command — use keychain tool instead
941
+ if (/security\s+find-generic-password.*-w/i.test(args.command)) {
942
+ return { success: false, error: "Use the 'keychain' tool instead of run_command for secrets. It masks sensitive values." };
943
+ }
926
944
  console.log(dim(` $ ${args.command}`));
927
945
  const { stdout, stderr } = await execAsync("/bin/bash", ["-c", args.command], {
928
946
  timeout: 30_000,
@@ -1068,6 +1086,73 @@ async function executeTool(name, args) {
1068
1086
  }
1069
1087
  }
1070
1088
 
1089
+ case "keychain": {
1090
+ const { promisify: prom } = await import("node:util");
1091
+ const { execFile: ef2 } = await import("node:child_process");
1092
+ const exec2 = prom(ef2);
1093
+ const account = args.account ?? "wispy";
1094
+
1095
+ if (process.platform !== "darwin") {
1096
+ return { success: false, error: "Keychain is only supported on macOS" };
1097
+ }
1098
+
1099
+ if (args.action === "get") {
1100
+ try {
1101
+ const { stdout } = await exec2("security", [
1102
+ "find-generic-password", "-s", args.service, "-a", account, "-w"
1103
+ ], { timeout: 5000 });
1104
+ const val = stdout.trim();
1105
+ // NEVER expose full secret — mask middle
1106
+ const masked = val.length > 8
1107
+ ? `${val.slice(0, 4)}${"*".repeat(Math.min(val.length - 8, 20))}${val.slice(-4)}`
1108
+ : "****";
1109
+ return { success: true, service: args.service, account, value_masked: masked, length: val.length };
1110
+ } catch {
1111
+ return { success: false, error: `No keychain entry found for service="${args.service}" account="${account}"` };
1112
+ }
1113
+ }
1114
+
1115
+ if (args.action === "set") {
1116
+ if (!args.value) return { success: false, error: "value is required for set action" };
1117
+ try {
1118
+ // Delete existing first (ignore error if not found)
1119
+ await exec2("security", [
1120
+ "delete-generic-password", "-s", args.service, "-a", account
1121
+ ]).catch(() => {});
1122
+ await exec2("security", [
1123
+ "add-generic-password", "-s", args.service, "-a", account, "-w", args.value
1124
+ ], { timeout: 5000 });
1125
+ return { success: true, message: `Stored secret for service="${args.service}" account="${account}"` };
1126
+ } catch (err) {
1127
+ return { success: false, error: err.message };
1128
+ }
1129
+ }
1130
+
1131
+ if (args.action === "delete") {
1132
+ try {
1133
+ await exec2("security", [
1134
+ "delete-generic-password", "-s", args.service, "-a", account
1135
+ ], { timeout: 5000 });
1136
+ return { success: true, message: `Deleted keychain entry for service="${args.service}"` };
1137
+ } catch {
1138
+ return { success: false, error: `No entry found for service="${args.service}"` };
1139
+ }
1140
+ }
1141
+
1142
+ if (args.action === "list") {
1143
+ try {
1144
+ const { stdout } = await exec2("/bin/bash", ["-c",
1145
+ `security dump-keychain 2>/dev/null | grep -A 4 "\"svce\"" | grep -E "svce|acct" | head -20`
1146
+ ], { timeout: 5000 });
1147
+ return { success: true, entries: stdout.trim() || "No entries found" };
1148
+ } catch {
1149
+ return { success: true, entries: "No entries found" };
1150
+ }
1151
+ }
1152
+
1153
+ return { success: false, error: "action must be get, set, delete, or list" };
1154
+ }
1155
+
1071
1156
  case "clipboard": {
1072
1157
  const { promisify: prom } = await import("node:util");
1073
1158
  const { execFile: ef2 } = await import("node:child_process");
@@ -1379,8 +1464,20 @@ Be concise. Your output feeds into the next stage.`;
1379
1464
  return { success: true, iterations: MAX_ITERATIONS, result: lastResult, verified: false, message: "Max iterations reached" };
1380
1465
  }
1381
1466
 
1382
- default:
1383
- return { success: false, error: `Unknown tool: ${name}` };
1467
+ default: {
1468
+ // Unknown tool try to execute as a skill via run_command
1469
+ // This handles cases where the AI hallucinates tools from skill descriptions
1470
+ const skills = await loadSkills();
1471
+ const matchedSkill = skills.find(s => s.name.toLowerCase() === name.toLowerCase());
1472
+ if (matchedSkill) {
1473
+ return {
1474
+ success: false,
1475
+ 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.`,
1476
+ skill_hint: matchedSkill.body.slice(0, 500),
1477
+ };
1478
+ }
1479
+ 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` };
1480
+ }
1384
1481
  }
1385
1482
  } catch (err) {
1386
1483
  return { success: false, error: err.message };
@@ -1408,6 +1505,69 @@ async function loadWorkMd() {
1408
1505
  return null;
1409
1506
  }
1410
1507
 
1508
+ // ---------------------------------------------------------------------------
1509
+ // Skill loader — loads SKILL.md files from multiple sources
1510
+ // Compatible with OpenClaw and Claude Code skill formats
1511
+ // ---------------------------------------------------------------------------
1512
+
1513
+ async function loadSkills() {
1514
+ const skillDirs = [
1515
+ // OpenClaw built-in skills
1516
+ "/opt/homebrew/lib/node_modules/openclaw/skills",
1517
+ // OpenClaw user skills
1518
+ path.join(os.homedir(), ".openclaw", "workspace", "skills"),
1519
+ // Wispy skills
1520
+ path.join(WISPY_DIR, "skills"),
1521
+ // Project-local skills
1522
+ path.resolve(".wispy", "skills"),
1523
+ // Claude Code skills (if installed)
1524
+ path.join(os.homedir(), ".claude", "skills"),
1525
+ ];
1526
+
1527
+ const skills = [];
1528
+ const { readdir: rd, stat: st } = await import("node:fs/promises");
1529
+
1530
+ for (const dir of skillDirs) {
1531
+ try {
1532
+ const entries = await rd(dir);
1533
+ for (const entry of entries) {
1534
+ const skillMdPath = path.join(dir, entry, "SKILL.md");
1535
+ try {
1536
+ const content = await readFile(skillMdPath, "utf8");
1537
+ // Parse frontmatter
1538
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1539
+ let name = entry;
1540
+ let description = "";
1541
+ let body = content;
1542
+
1543
+ if (fmMatch) {
1544
+ const fm = fmMatch[1];
1545
+ body = fmMatch[2];
1546
+ const nameMatch = fm.match(/name:\s*["']?(.+?)["']?\s*$/m);
1547
+ const descMatch = fm.match(/description:\s*["'](.+?)["']\s*$/m);
1548
+ if (nameMatch) name = nameMatch[1].trim();
1549
+ if (descMatch) description = descMatch[1].trim();
1550
+ }
1551
+
1552
+ skills.push({ name, description, body: body.trim(), path: skillMdPath, source: dir });
1553
+ } catch { /* no SKILL.md */ }
1554
+ }
1555
+ } catch { /* dir doesn't exist */ }
1556
+ }
1557
+
1558
+ return skills;
1559
+ }
1560
+
1561
+ function matchSkills(prompt, skills) {
1562
+ const lower = prompt.toLowerCase();
1563
+ return skills.filter(skill => {
1564
+ const nameMatch = lower.includes(skill.name.toLowerCase());
1565
+ const descWords = skill.description.toLowerCase().split(/\s+/);
1566
+ const descMatch = descWords.some(w => w.length > 4 && lower.includes(w));
1567
+ return nameMatch || descMatch;
1568
+ });
1569
+ }
1570
+
1411
1571
  async function buildSystemPrompt(messages = []) {
1412
1572
  // Detect user's language from last message for system prompt hint
1413
1573
  const lastUserMsg = messages?.find ? [...messages].reverse().find(m => m.role === "user")?.content ?? "" : "";
@@ -1439,12 +1599,14 @@ async function buildSystemPrompt(messages = []) {
1439
1599
  " - NEVER reply in Korean when the user wrote in English.",
1440
1600
  "",
1441
1601
  "## Tools",
1442
- "You have these tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result.",
1602
+ "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.",
1443
1603
  "- file_edit: for targeted text replacement (prefer over write_file for edits)",
1444
1604
  "- file_search: grep across codebase",
1445
1605
  "- git: any git command",
1446
1606
  "- web_fetch: read URL content",
1607
+ "- keychain: macOS Keychain secrets (ALWAYS use this for secrets, NEVER run_command)",
1447
1608
  "- clipboard: copy/paste system clipboard",
1609
+ "- SECURITY: Never show full API keys or secrets. Always use keychain tool which masks values.",
1448
1610
  "Use them proactively. Briefly mention what you're doing.",
1449
1611
  "",
1450
1612
  ];
@@ -1471,6 +1633,25 @@ async function buildSystemPrompt(messages = []) {
1471
1633
  parts.push("");
1472
1634
  }
1473
1635
 
1636
+ // Load and inject matching skills
1637
+ const allSkills = await loadSkills();
1638
+ if (allSkills.length > 0 && lastUserMsg) {
1639
+ const matched = matchSkills(lastUserMsg, allSkills);
1640
+ if (matched.length > 0) {
1641
+ parts.push("## Active Skills (instructions — use run_command/web_fetch to execute)");
1642
+ parts.push("Skills are NOT tools — they are guides. Use run_command to execute the commands described in them.");
1643
+ for (const skill of matched.slice(0, 3)) { // Max 3 skills per turn
1644
+ parts.push(`### ${skill.name}`);
1645
+ parts.push(skill.body.slice(0, 5000));
1646
+ parts.push("");
1647
+ }
1648
+ }
1649
+ // Always list available skills
1650
+ parts.push(`## Available Skills (${allSkills.length} installed)`);
1651
+ parts.push(allSkills.map(s => `- ${s.name}: ${s.description.slice(0, 60)}`).join("\n"));
1652
+ parts.push("");
1653
+ }
1654
+
1474
1655
  return parts.join("\n");
1475
1656
  }
1476
1657
 
@@ -2044,6 +2225,30 @@ ${bold("Wispy Commands:")}
2044
2225
  return true;
2045
2226
  }
2046
2227
 
2228
+ if (cmd === "/skills") {
2229
+ const skills = await loadSkills();
2230
+ if (skills.length === 0) {
2231
+ console.log(dim("No skills installed."));
2232
+ console.log(dim("Add skills to ~/.wispy/skills/ or install OpenClaw skills."));
2233
+ } else {
2234
+ console.log(bold(`\n🧩 Skills (${skills.length} installed):\n`));
2235
+ const bySource = {};
2236
+ for (const s of skills) {
2237
+ const src = s.source.includes("openclaw") ? "OpenClaw" : s.source.includes(".wispy") ? "Wispy" : s.source.includes(".claude") ? "Claude" : "Project";
2238
+ if (!bySource[src]) bySource[src] = [];
2239
+ bySource[src].push(s);
2240
+ }
2241
+ for (const [src, sks] of Object.entries(bySource)) {
2242
+ console.log(` ${bold(src)} (${sks.length}):`);
2243
+ for (const s of sks) {
2244
+ console.log(` ${green(s.name.padEnd(20))} ${dim(s.description.slice(0, 50))}`);
2245
+ }
2246
+ console.log("");
2247
+ }
2248
+ }
2249
+ return true;
2250
+ }
2251
+
2047
2252
  if (cmd === "/sessions" || cmd === "/ls") {
2048
2253
  const wsList = await listWorkstreams();
2049
2254
  if (wsList.length === 0) {
@@ -2498,6 +2703,7 @@ ${bold("In-session commands:")}
2498
2703
  /workstreams List all workstreams
2499
2704
  /overview Director view — all workstreams at a glance
2500
2705
  /search <keyword> Search across all workstreams
2706
+ /skills List installed skills (OpenClaw/Claude compatible)
2501
2707
  /sessions List all sessions
2502
2708
  /delete <name> Delete a session
2503
2709
  /export [md|clipboard] Export conversation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "🌿 Wispy — AI workspace assistant with multi-agent orchestration",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Minseo & Poropo",