wispy-cli 0.2.2 → 0.3.1
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/lib/wispy-repl.mjs +313 -1
- package/package.json +1 -1
package/lib/wispy-repl.mjs
CHANGED
|
@@ -676,6 +676,80 @@ const TOOL_DEFINITIONS = [
|
|
|
676
676
|
required: ["query"],
|
|
677
677
|
},
|
|
678
678
|
},
|
|
679
|
+
{
|
|
680
|
+
name: "file_edit",
|
|
681
|
+
description: "Edit a file by replacing specific text. More precise than write_file — use this for targeted changes.",
|
|
682
|
+
parameters: {
|
|
683
|
+
type: "object",
|
|
684
|
+
properties: {
|
|
685
|
+
path: { type: "string", description: "File path" },
|
|
686
|
+
old_text: { type: "string", description: "Exact text to find and replace" },
|
|
687
|
+
new_text: { type: "string", description: "Replacement text" },
|
|
688
|
+
},
|
|
689
|
+
required: ["path", "old_text", "new_text"],
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
name: "file_search",
|
|
694
|
+
description: "Search for text patterns in files recursively (like grep). Returns matching lines with file paths and line numbers.",
|
|
695
|
+
parameters: {
|
|
696
|
+
type: "object",
|
|
697
|
+
properties: {
|
|
698
|
+
pattern: { type: "string", description: "Text or regex pattern to search for" },
|
|
699
|
+
path: { type: "string", description: "Directory to search in (default: current dir)" },
|
|
700
|
+
file_glob: { type: "string", description: "File glob filter (e.g., '*.ts', '*.py')" },
|
|
701
|
+
},
|
|
702
|
+
required: ["pattern"],
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
name: "git",
|
|
707
|
+
description: "Run git operations: status, diff, log, branch, add, commit, stash, checkout. Use for version control tasks.",
|
|
708
|
+
parameters: {
|
|
709
|
+
type: "object",
|
|
710
|
+
properties: {
|
|
711
|
+
command: { type: "string", description: "Git subcommand and args (e.g., 'status', 'diff --cached', 'log --oneline -10', 'commit -m \"msg\"')" },
|
|
712
|
+
},
|
|
713
|
+
required: ["command"],
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
name: "web_fetch",
|
|
718
|
+
description: "Fetch content from a URL and return it as text/markdown. Use to read web pages, docs, APIs.",
|
|
719
|
+
parameters: {
|
|
720
|
+
type: "object",
|
|
721
|
+
properties: {
|
|
722
|
+
url: { type: "string", description: "URL to fetch" },
|
|
723
|
+
},
|
|
724
|
+
required: ["url"],
|
|
725
|
+
},
|
|
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
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: "clipboard",
|
|
743
|
+
description: "Copy text to clipboard (macOS/Linux) or read current clipboard contents.",
|
|
744
|
+
parameters: {
|
|
745
|
+
type: "object",
|
|
746
|
+
properties: {
|
|
747
|
+
action: { type: "string", enum: ["copy", "paste"], description: "copy: write to clipboard, paste: read from clipboard" },
|
|
748
|
+
text: { type: "string", description: "Text to copy (only for copy action)" },
|
|
749
|
+
},
|
|
750
|
+
required: ["action"],
|
|
751
|
+
},
|
|
752
|
+
},
|
|
679
753
|
{
|
|
680
754
|
name: "spawn_agent",
|
|
681
755
|
description: "Spawn a sub-agent for a well-scoped task. Use for sidecar tasks that can run in parallel. Do NOT spawn for the immediate blocking step — do that yourself. Each agent gets its own context. Prefer concrete, bounded tasks with clear deliverables.",
|
|
@@ -863,6 +937,10 @@ async function executeTool(name, args) {
|
|
|
863
937
|
}
|
|
864
938
|
|
|
865
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
|
+
}
|
|
866
944
|
console.log(dim(` $ ${args.command}`));
|
|
867
945
|
const { stdout, stderr } = await execAsync("/bin/bash", ["-c", args.command], {
|
|
868
946
|
timeout: 30_000,
|
|
@@ -939,6 +1017,170 @@ async function executeTool(name, args) {
|
|
|
939
1017
|
};
|
|
940
1018
|
}
|
|
941
1019
|
|
|
1020
|
+
case "file_edit": {
|
|
1021
|
+
const filePath = args.path.replace(/^~/, os.homedir());
|
|
1022
|
+
try {
|
|
1023
|
+
const content = await readFile(filePath, "utf8");
|
|
1024
|
+
if (!content.includes(args.old_text)) {
|
|
1025
|
+
return { success: false, error: `Text not found in ${filePath}` };
|
|
1026
|
+
}
|
|
1027
|
+
const newContent = content.replace(args.old_text, args.new_text);
|
|
1028
|
+
await writeFile(filePath, newContent, "utf8");
|
|
1029
|
+
return { success: true, message: `Edited ${filePath}: replaced ${args.old_text.length} chars` };
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
return { success: false, error: err.message };
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
case "file_search": {
|
|
1036
|
+
const { promisify: prom } = await import("node:util");
|
|
1037
|
+
const { execFile: ef2 } = await import("node:child_process");
|
|
1038
|
+
const exec2 = prom(ef2);
|
|
1039
|
+
const searchPath = (args.path || ".").replace(/^~/, os.homedir());
|
|
1040
|
+
const glob = args.file_glob ? `--include="${args.file_glob}"` : "";
|
|
1041
|
+
try {
|
|
1042
|
+
const { stdout } = await exec2("/bin/bash", ["-c",
|
|
1043
|
+
`grep -rn ${glob} "${args.pattern}" "${searchPath}" 2>/dev/null | head -30`
|
|
1044
|
+
], { timeout: 10_000 });
|
|
1045
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
1046
|
+
return { success: true, matches: lines.length, results: stdout.trim().slice(0, 5000) };
|
|
1047
|
+
} catch {
|
|
1048
|
+
return { success: true, matches: 0, results: "No matches found." };
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
case "git": {
|
|
1053
|
+
const { promisify: prom } = await import("node:util");
|
|
1054
|
+
const { execFile: ef2 } = await import("node:child_process");
|
|
1055
|
+
const exec2 = prom(ef2);
|
|
1056
|
+
console.log(dim(` $ git ${args.command}`));
|
|
1057
|
+
try {
|
|
1058
|
+
const { stdout, stderr } = await exec2("/bin/bash", ["-c", `git ${args.command}`], {
|
|
1059
|
+
timeout: 15_000, cwd: process.cwd(),
|
|
1060
|
+
});
|
|
1061
|
+
return { success: true, output: (stdout + (stderr ? `\n${stderr}` : "")).trim().slice(0, 5000) };
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
return { success: false, error: err.stderr?.trim() || err.message };
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
case "web_fetch": {
|
|
1068
|
+
try {
|
|
1069
|
+
const resp = await fetch(args.url, {
|
|
1070
|
+
headers: { "User-Agent": "Wispy/0.2" },
|
|
1071
|
+
signal: AbortSignal.timeout(15_000),
|
|
1072
|
+
});
|
|
1073
|
+
const contentType = resp.headers.get("content-type") ?? "";
|
|
1074
|
+
const text = await resp.text();
|
|
1075
|
+
// Basic HTML → text conversion
|
|
1076
|
+
const cleaned = contentType.includes("html")
|
|
1077
|
+
? text.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
1078
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
1079
|
+
.replace(/<[^>]+>/g, " ")
|
|
1080
|
+
.replace(/\s+/g, " ")
|
|
1081
|
+
.trim()
|
|
1082
|
+
: text;
|
|
1083
|
+
return { success: true, content: cleaned.slice(0, 10_000), contentType, status: resp.status };
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
return { success: false, error: err.message };
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
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
|
+
|
|
1156
|
+
case "clipboard": {
|
|
1157
|
+
const { promisify: prom } = await import("node:util");
|
|
1158
|
+
const { execFile: ef2 } = await import("node:child_process");
|
|
1159
|
+
const exec2 = prom(ef2);
|
|
1160
|
+
if (args.action === "copy") {
|
|
1161
|
+
const { exec: execCb } = await import("node:child_process");
|
|
1162
|
+
const execP = prom(execCb);
|
|
1163
|
+
try {
|
|
1164
|
+
// macOS: pbcopy, Linux: xclip
|
|
1165
|
+
const copyCmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard";
|
|
1166
|
+
await execP(`echo "${args.text.replace(/"/g, '\\"')}" | ${copyCmd}`);
|
|
1167
|
+
return { success: true, message: `Copied ${args.text.length} chars to clipboard` };
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
return { success: false, error: err.message };
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (args.action === "paste") {
|
|
1173
|
+
try {
|
|
1174
|
+
const pasteCmd = process.platform === "darwin" ? "pbpaste" : "xclip -selection clipboard -o";
|
|
1175
|
+
const { stdout } = await exec2("/bin/bash", ["-c", pasteCmd], { timeout: 3000 });
|
|
1176
|
+
return { success: true, content: stdout.slice(0, 5000) };
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
return { success: false, error: err.message };
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return { success: false, error: "action must be 'copy' or 'paste'" };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
942
1184
|
case "spawn_agent": {
|
|
943
1185
|
const role = args.role ?? "worker";
|
|
944
1186
|
const tierMap = { explorer: "cheap", planner: "mid", worker: "mid", reviewer: "mid" };
|
|
@@ -1282,7 +1524,14 @@ async function buildSystemPrompt(messages = []) {
|
|
|
1282
1524
|
" - NEVER reply in Korean when the user wrote in English.",
|
|
1283
1525
|
"",
|
|
1284
1526
|
"## Tools",
|
|
1285
|
-
"You have: read_file, write_file, run_command, list_directory, web_search, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result.",
|
|
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.",
|
|
1528
|
+
"- file_edit: for targeted text replacement (prefer over write_file for edits)",
|
|
1529
|
+
"- file_search: grep across codebase",
|
|
1530
|
+
"- git: any git command",
|
|
1531
|
+
"- web_fetch: read URL content",
|
|
1532
|
+
"- keychain: macOS Keychain secrets (ALWAYS use this for secrets, NEVER run_command)",
|
|
1533
|
+
"- clipboard: copy/paste system clipboard",
|
|
1534
|
+
"- SECURITY: Never show full API keys or secrets. Always use keychain tool which masks values.",
|
|
1286
1535
|
"Use them proactively. Briefly mention what you're doing.",
|
|
1287
1536
|
"",
|
|
1288
1537
|
];
|
|
@@ -1882,6 +2131,66 @@ ${bold("Wispy Commands:")}
|
|
|
1882
2131
|
return true;
|
|
1883
2132
|
}
|
|
1884
2133
|
|
|
2134
|
+
if (cmd === "/sessions" || cmd === "/ls") {
|
|
2135
|
+
const wsList = await listWorkstreams();
|
|
2136
|
+
if (wsList.length === 0) {
|
|
2137
|
+
console.log(dim("No sessions yet."));
|
|
2138
|
+
} else {
|
|
2139
|
+
console.log(bold("\n📂 Sessions:\n"));
|
|
2140
|
+
for (const ws of wsList) {
|
|
2141
|
+
const conv = await loadWorkstreamConversation(ws);
|
|
2142
|
+
const msgs = conv.filter(m => m.role === "user").length;
|
|
2143
|
+
const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
|
|
2144
|
+
console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgs} messages`)}`);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
console.log(dim(`\nSwitch: wispy -w <name> | Delete: /delete <name>`));
|
|
2148
|
+
return true;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
if (cmd === "/delete" || cmd === "/rm") {
|
|
2152
|
+
const target = parts[1];
|
|
2153
|
+
if (!target) { console.log(yellow("Usage: /delete <workstream-name>")); return true; }
|
|
2154
|
+
const wsPath = path.join(CONVERSATIONS_DIR, `${target}.json`);
|
|
2155
|
+
try {
|
|
2156
|
+
const { unlink } = await import("node:fs/promises");
|
|
2157
|
+
await unlink(wsPath);
|
|
2158
|
+
// Also delete work.md and plan
|
|
2159
|
+
await unlink(path.join(CONVERSATIONS_DIR, `${target}.work.md`)).catch(() => {});
|
|
2160
|
+
await unlink(path.join(CONVERSATIONS_DIR, `${target}.plan.json`)).catch(() => {});
|
|
2161
|
+
console.log(green(`🗑️ Deleted session "${target}"`));
|
|
2162
|
+
} catch {
|
|
2163
|
+
console.log(red(`Session "${target}" not found.`));
|
|
2164
|
+
}
|
|
2165
|
+
return true;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
if (cmd === "/export") {
|
|
2169
|
+
const conv = await loadConversation();
|
|
2170
|
+
const userAssistant = conv.filter(m => m.role === "user" || m.role === "assistant");
|
|
2171
|
+
if (userAssistant.length === 0) { console.log(dim("Nothing to export.")); return true; }
|
|
2172
|
+
|
|
2173
|
+
const format = parts[1] ?? "md";
|
|
2174
|
+
const lines = userAssistant.map(m => {
|
|
2175
|
+
const role = m.role === "user" ? "**You**" : "**Wispy**";
|
|
2176
|
+
return `${role}: ${m.content}`;
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
if (format === "clipboard" || format === "copy") {
|
|
2180
|
+
const { execSync: es } = await import("node:child_process");
|
|
2181
|
+
try {
|
|
2182
|
+
const text = lines.join("\n\n");
|
|
2183
|
+
es(`echo "${text.replace(/"/g, '\\"').slice(0, 50000)}" | pbcopy`);
|
|
2184
|
+
console.log(green(`📋 Copied ${userAssistant.length} messages to clipboard`));
|
|
2185
|
+
} catch { console.log(red("Clipboard copy failed.")); }
|
|
2186
|
+
} else {
|
|
2187
|
+
const exportPath = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.export.md`);
|
|
2188
|
+
await writeFile(exportPath, `# ${ACTIVE_WORKSTREAM}\n\n${lines.join("\n\n")}\n`, "utf8");
|
|
2189
|
+
console.log(green(`📄 Exported to ${exportPath}`));
|
|
2190
|
+
}
|
|
2191
|
+
return true;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
1885
2194
|
if (cmd === "/provider") {
|
|
1886
2195
|
console.log(dim(`Provider: ${PROVIDERS[PROVIDER]?.label ?? PROVIDER}`));
|
|
1887
2196
|
console.log(dim(`Model: ${MODEL}`));
|
|
@@ -2276,6 +2585,9 @@ ${bold("In-session commands:")}
|
|
|
2276
2585
|
/workstreams List all workstreams
|
|
2277
2586
|
/overview Director view — all workstreams at a glance
|
|
2278
2587
|
/search <keyword> Search across all workstreams
|
|
2588
|
+
/sessions List all sessions
|
|
2589
|
+
/delete <name> Delete a session
|
|
2590
|
+
/export [md|clipboard] Export conversation
|
|
2279
2591
|
/provider Show current provider info
|
|
2280
2592
|
/quit Exit
|
|
2281
2593
|
|