wispy-cli 2.3.0 → 2.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.mjs +107 -11
- package/core/migrate.mjs +89 -2
- package/lib/command-registry.mjs +112 -0
- package/lib/wispy-repl.mjs +151 -1
- package/lib/wispy-tui.mjs +102 -1
- package/package.json +1 -1
package/bin/wispy.mjs
CHANGED
|
@@ -312,13 +312,63 @@ ${_bold("Usage:")}
|
|
|
312
312
|
// ── completion sub-command ────────────────────────────────────────────────────
|
|
313
313
|
if (args[0] === "completion") {
|
|
314
314
|
const shell = args[1] ?? "bash";
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
"
|
|
315
|
+
|
|
316
|
+
// Commands with descriptions for rich completions
|
|
317
|
+
const cmdSpecs = [
|
|
318
|
+
{ cmd: "ws", desc: "Workstream management (list/new/switch/archive/delete/status/search)" },
|
|
319
|
+
{ cmd: "trust", desc: "Trust level & security policies (careful/balanced/yolo)" },
|
|
320
|
+
{ cmd: "where", desc: "Show current mode & context" },
|
|
321
|
+
{ cmd: "handoff", desc: "Generate handoff summary & sync" },
|
|
322
|
+
{ cmd: "skill", desc: "Skill management (list/show)" },
|
|
323
|
+
{ cmd: "teach", desc: "Create skill from current conversation" },
|
|
324
|
+
{ cmd: "improve", desc: "Improve an existing skill" },
|
|
325
|
+
{ cmd: "dry", desc: "Run next command in dry-run (preview-only) mode" },
|
|
326
|
+
{ cmd: "deploy", desc: "Deployment helpers (Dockerfile/compose/systemd/vps/…)" },
|
|
327
|
+
{ cmd: "server", desc: "Start the Wispy API server" },
|
|
328
|
+
{ cmd: "node", desc: "Multi-machine node management (pair/connect/list/status)" },
|
|
329
|
+
{ cmd: "channel", desc: "Messaging channel setup (telegram/discord/slack/email/…)" },
|
|
330
|
+
{ cmd: "cron", desc: "Scheduled tasks (list/add/remove/run/history/start)" },
|
|
331
|
+
{ cmd: "audit", desc: "View audit log & replay sessions" },
|
|
332
|
+
{ cmd: "log", desc: "Alias for audit" },
|
|
333
|
+
{ cmd: "sync", desc: "Sync with remote server (push/pull/status/auto)" },
|
|
334
|
+
{ cmd: "setup", desc: "Interactive setup wizard" },
|
|
335
|
+
{ cmd: "init", desc: "Alias for setup" },
|
|
336
|
+
{ cmd: "update", desc: "Update wispy-cli to latest version" },
|
|
337
|
+
{ cmd: "tui", desc: "Launch the workspace TUI" },
|
|
338
|
+
{ cmd: "migrate", desc: "Import from OpenClaw or other sources" },
|
|
339
|
+
{ cmd: "version", desc: "Show version" },
|
|
340
|
+
{ cmd: "doctor", desc: "Check system health & API keys" },
|
|
341
|
+
{ cmd: "help", desc: "Show help (optionally for a specific command)" },
|
|
342
|
+
{ cmd: "completion", desc: "Print shell completion script (bash/zsh/fish)" },
|
|
343
|
+
{ cmd: "status", desc: "Show wispy status & remote connection info" },
|
|
344
|
+
{ cmd: "connect", desc: "Connect to a remote Wispy server" },
|
|
345
|
+
{ cmd: "disconnect", desc: "Disconnect from remote server (go back to local)" },
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
// Sub-command completions for nested commands
|
|
349
|
+
const subCmds = {
|
|
350
|
+
ws: ["new", "switch", "archive", "delete", "status", "search"],
|
|
351
|
+
trust: ["careful", "balanced", "yolo", "log"],
|
|
352
|
+
skill: ["list", "show"],
|
|
353
|
+
deploy: ["init", "dockerfile", "compose", "systemd", "railway", "fly", "render", "modal", "daytona", "vps", "status"],
|
|
354
|
+
cron: ["list", "add", "remove", "run", "history", "start"],
|
|
355
|
+
audit: ["replay", "export", "--today", "--session", "--tool", "--limit"],
|
|
356
|
+
channel: ["setup", "list", "test"],
|
|
357
|
+
node: ["pair", "connect", "list", "status", "remove"],
|
|
358
|
+
sync: ["push", "pull", "status", "auto"],
|
|
359
|
+
server: ["start", "stop", "status"],
|
|
360
|
+
completion: ["bash", "zsh", "fish"],
|
|
361
|
+
help: cmdSpecs.map(c => c.cmd),
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const cmds = cmdSpecs.map(c => c.cmd);
|
|
319
365
|
const flags = ["--help", "--version", "--debug", "--serve", "--telegram", "--discord", "--slack"];
|
|
320
366
|
|
|
321
367
|
if (shell === "bash") {
|
|
368
|
+
const subCmdsStr = Object.entries(subCmds)
|
|
369
|
+
.map(([k, v]) => ` ${k}) COMPREPLY=( $(compgen -W "${v.join(' ')}" -- "\${cur}") ) ;;`)
|
|
370
|
+
.join('\n');
|
|
371
|
+
|
|
322
372
|
console.log(`# wispy bash completion
|
|
323
373
|
# Add to ~/.bashrc: eval "$(wispy completion bash)"
|
|
324
374
|
_wispy_completion() {
|
|
@@ -331,30 +381,69 @@ _wispy_completion() {
|
|
|
331
381
|
|
|
332
382
|
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
333
383
|
COMPREPLY=( $(compgen -W "\${commands} \${flags}" -- "\${cur}") )
|
|
384
|
+
elif [[ \${COMP_CWORD} -eq 2 ]]; then
|
|
385
|
+
case "\${prev}" in
|
|
386
|
+
${subCmdsStr}
|
|
387
|
+
esac
|
|
334
388
|
fi
|
|
335
389
|
}
|
|
336
390
|
complete -F _wispy_completion wispy`);
|
|
391
|
+
|
|
337
392
|
} else if (shell === "zsh") {
|
|
393
|
+
const cmdDescLines = cmdSpecs.map(c => ` '${c.cmd}:${c.desc.replace(/'/g, "''")}'`).join('\n');
|
|
394
|
+
const subCmdLines = Object.entries(subCmds)
|
|
395
|
+
.map(([k, v]) => ` (${k}) _values '${k} commands' ${v.map(s => `'${s}'`).join(' ')} ;;`)
|
|
396
|
+
.join('\n');
|
|
397
|
+
|
|
338
398
|
console.log(`# wispy zsh completion
|
|
339
399
|
# Add to ~/.zshrc: eval "$(wispy completion zsh)"
|
|
400
|
+
# Or for permanent use: wispy completion zsh > /usr/local/share/zsh/site-functions/_wispy
|
|
340
401
|
_wispy() {
|
|
402
|
+
local context state state_descr line
|
|
403
|
+
typeset -A opt_args
|
|
404
|
+
|
|
341
405
|
local -a commands
|
|
342
406
|
commands=(
|
|
343
|
-
|
|
407
|
+
${cmdDescLines}
|
|
344
408
|
)
|
|
345
|
-
|
|
346
|
-
flags=(${flags.map(f => `'${f}'`).join(" ")})
|
|
409
|
+
|
|
347
410
|
_arguments -C \\
|
|
348
|
-
'
|
|
411
|
+
'(- *)'{-h,--help}'[Show help]' \\
|
|
412
|
+
'(- *)'{-v,--version}'[Show version]' \\
|
|
413
|
+
'--debug[Enable verbose logs]' \\
|
|
414
|
+
'--serve[Start all channel bots]' \\
|
|
415
|
+
'--telegram[Start Telegram bot]' \\
|
|
416
|
+
'--discord[Start Discord bot]' \\
|
|
417
|
+
'--slack[Start Slack bot]' \\
|
|
418
|
+
'1:command:->cmd' \\
|
|
349
419
|
'*::arg:->args'
|
|
420
|
+
|
|
350
421
|
case $state in
|
|
351
|
-
|
|
422
|
+
cmd)
|
|
423
|
+
_describe 'wispy commands' commands
|
|
424
|
+
;;
|
|
425
|
+
args)
|
|
426
|
+
case $line[1] in
|
|
427
|
+
${subCmdLines}
|
|
428
|
+
esac
|
|
429
|
+
;;
|
|
352
430
|
esac
|
|
353
431
|
}
|
|
354
432
|
compdef _wispy wispy`);
|
|
433
|
+
|
|
355
434
|
} else if (shell === "fish") {
|
|
356
|
-
const
|
|
357
|
-
|
|
435
|
+
const mainComps = cmdSpecs
|
|
436
|
+
.map(c => `complete -c wispy -f -n '__fish_use_subcommand' -a '${c.cmd}' -d '${c.desc.replace(/'/g, "\\'")}'`)
|
|
437
|
+
.join("\n");
|
|
438
|
+
const subComps = Object.entries(subCmds)
|
|
439
|
+
.flatMap(([k, v]) => v.map(s => `complete -c wispy -f -n '__fish_seen_subcommand_from ${k}' -a '${s}'`))
|
|
440
|
+
.join("\n");
|
|
441
|
+
|
|
442
|
+
console.log(`# wispy fish completion
|
|
443
|
+
# Save to ~/.config/fish/completions/wispy.fish
|
|
444
|
+
${mainComps}
|
|
445
|
+
${subComps}`);
|
|
446
|
+
|
|
358
447
|
} else {
|
|
359
448
|
console.error(_red(`❌ Unknown shell: ${shell}. Use: bash, zsh, or fish`));
|
|
360
449
|
process.exit(2);
|
|
@@ -635,6 +724,13 @@ if (args[0] === "setup" || args[0] === "init") {
|
|
|
635
724
|
await wizard.run();
|
|
636
725
|
}
|
|
637
726
|
} catch (e) { friendlyError(e); }
|
|
727
|
+
|
|
728
|
+
// Shell completion tip
|
|
729
|
+
console.log(_dim(`
|
|
730
|
+
💡 Tip: Enable tab completion in your shell:
|
|
731
|
+
eval "$(wispy completion zsh)" # zsh — add to ~/.zshrc for permanent
|
|
732
|
+
eval "$(wispy completion bash)" # bash — add to ~/.bashrc for permanent
|
|
733
|
+
`));
|
|
638
734
|
process.exit(0);
|
|
639
735
|
}
|
|
640
736
|
|
package/core/migrate.mjs
CHANGED
|
@@ -71,6 +71,7 @@ export class OpenClawMigrator {
|
|
|
71
71
|
await this._migrateWorkspaceFiles(dryRun);
|
|
72
72
|
await this._migrateCronJobs(dryRun);
|
|
73
73
|
await this._migrateChannels(dryRun);
|
|
74
|
+
await this._migrateApiKeys(dryRun);
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
return {
|
|
@@ -274,9 +275,11 @@ export class OpenClawMigrator {
|
|
|
274
275
|
} catch { /* empty */ }
|
|
275
276
|
|
|
276
277
|
// Extract Telegram config
|
|
277
|
-
const telegramToken = config.telegram?.
|
|
278
|
+
const telegramToken = config.channels?.telegram?.botToken
|
|
279
|
+
?? config.telegram?.token
|
|
278
280
|
?? config.channels?.telegram?.token
|
|
279
|
-
?? config.plugins?.telegram?.token
|
|
281
|
+
?? config.plugins?.telegram?.token
|
|
282
|
+
?? config.plugins?.entries?.telegram?.config?.botToken;
|
|
280
283
|
|
|
281
284
|
if (telegramToken && !wispyChannels.telegram) {
|
|
282
285
|
wispyChannels.telegram = { token: telegramToken };
|
|
@@ -302,6 +305,83 @@ export class OpenClawMigrator {
|
|
|
302
305
|
}
|
|
303
306
|
}
|
|
304
307
|
|
|
308
|
+
// ── API keys migration ───────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
async _migrateApiKeys(dryRun) {
|
|
311
|
+
// Read API keys from environment (same as OpenClaw uses)
|
|
312
|
+
const keyMap = {
|
|
313
|
+
google: { env: ["GOOGLE_AI_KEY", "GOOGLE_GENERATIVE_AI_KEY", "GEMINI_API_KEY"], model: "gemini-2.5-flash" },
|
|
314
|
+
anthropic: { env: ["ANTHROPIC_API_KEY"], model: "claude-sonnet-4-20250514" },
|
|
315
|
+
openai: { env: ["OPENAI_API_KEY"], model: "gpt-4o" },
|
|
316
|
+
groq: { env: ["GROQ_API_KEY"], model: "llama-3.3-70b-versatile" },
|
|
317
|
+
deepseek: { env: ["DEEPSEEK_API_KEY"], model: "deepseek-chat" },
|
|
318
|
+
openrouter:{ env: ["OPENROUTER_API_KEY"], model: "anthropic/claude-sonnet-4-20250514" },
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Also check macOS keychain
|
|
322
|
+
const { execSync } = await import("node:child_process");
|
|
323
|
+
function keychainGet(service, account) {
|
|
324
|
+
try {
|
|
325
|
+
return execSync(`security find-generic-password -s "${service}" -a "${account}" -w 2>/dev/null`, { encoding: "utf8" }).trim();
|
|
326
|
+
} catch { return null; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const keychainMap = {
|
|
330
|
+
google: { service: "google-ai-key", account: "poropo" },
|
|
331
|
+
anthropic: { service: "anthropic-api-key", account: "poropo" },
|
|
332
|
+
openai: { service: "openai-api-key", account: "poropo" },
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const configPath = join(this.wispyDir, "config.json");
|
|
336
|
+
let config = {};
|
|
337
|
+
try { config = JSON.parse(await readFile(configPath, "utf8")); } catch { /* empty */ }
|
|
338
|
+
if (!config.providers) config.providers = {};
|
|
339
|
+
|
|
340
|
+
let found = 0;
|
|
341
|
+
for (const [provider, info] of Object.entries(keyMap)) {
|
|
342
|
+
if (config.providers[provider]?.apiKey) continue; // already configured
|
|
343
|
+
|
|
344
|
+
// Check env vars
|
|
345
|
+
let key = null;
|
|
346
|
+
for (const envName of info.env) {
|
|
347
|
+
if (process.env[envName]) { key = process.env[envName]; break; }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check keychain (macOS)
|
|
351
|
+
if (!key && keychainMap[provider]) {
|
|
352
|
+
key = keychainGet(keychainMap[provider].service, keychainMap[provider].account);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Check ~/.zshenv for exports
|
|
356
|
+
if (!key) {
|
|
357
|
+
try {
|
|
358
|
+
const zshenv = await readFile(join(homedir(), ".zshenv"), "utf8");
|
|
359
|
+
for (const envName of info.env) {
|
|
360
|
+
const match = zshenv.match(new RegExp(`export\\s+${envName}=["']?([^"'\\n]+)`));
|
|
361
|
+
if (match) { key = match[1]; break; }
|
|
362
|
+
}
|
|
363
|
+
} catch { /* no zshenv */ }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (key) {
|
|
367
|
+
found++;
|
|
368
|
+
this.report.apiKeys = this.report.apiKeys || [];
|
|
369
|
+
this.report.apiKeys.push({ provider, masked: key.slice(0, 6) + "..." + key.slice(-4) });
|
|
370
|
+
|
|
371
|
+
if (!dryRun) {
|
|
372
|
+
config.providers[provider] = { apiKey: key, model: info.model };
|
|
373
|
+
if (!config.defaultProvider) config.defaultProvider = provider;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (found > 0 && !dryRun) {
|
|
379
|
+
config.onboarded = true;
|
|
380
|
+
await mkdir(this.wispyDir, { recursive: true });
|
|
381
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
305
385
|
// ── Report formatting ─────────────────────────────────────────────────────────
|
|
306
386
|
|
|
307
387
|
formatReport() {
|
|
@@ -345,6 +425,13 @@ export class OpenClawMigrator {
|
|
|
345
425
|
}
|
|
346
426
|
}
|
|
347
427
|
|
|
428
|
+
if (r.apiKeys?.length > 0) {
|
|
429
|
+
lines.push(`🔑 API Keys (${r.apiKeys.length}):`);
|
|
430
|
+
for (const k of r.apiKeys) {
|
|
431
|
+
lines.push(` import: ${k.provider} (${k.masked})`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
348
435
|
if (r.errors.length > 0) {
|
|
349
436
|
lines.push(`⚠️ Errors (${r.errors.length}):`);
|
|
350
437
|
for (const e of r.errors) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command-registry.mjs — Shared command registry for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Used by REPL completer, TUI CommandPalette, and shell completion.
|
|
5
|
+
* Keep this file in sync whenever new slash commands are added.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const COMMANDS = [
|
|
9
|
+
// ── Workstream ──────────────────────────────────────────────────────────────
|
|
10
|
+
{ cmd: '/ws', desc: 'List workstreams', category: 'Workstream' },
|
|
11
|
+
{ cmd: '/ws new <name>', desc: 'Create workstream', category: 'Workstream' },
|
|
12
|
+
{ cmd: '/ws <name>', desc: 'Switch workstream', category: 'Workstream' },
|
|
13
|
+
{ cmd: '/ws status', desc: 'All workstreams overview', category: 'Workstream' },
|
|
14
|
+
{ cmd: '/ws search <query>', desc: 'Search across workstreams', category: 'Workstream' },
|
|
15
|
+
{ cmd: '/ws archive <name>', desc: 'Archive workstream', category: 'Workstream' },
|
|
16
|
+
{ cmd: '/ws delete <name>', desc: 'Delete workstream', category: 'Workstream' },
|
|
17
|
+
|
|
18
|
+
// ── Trust ───────────────────────────────────────────────────────────────────
|
|
19
|
+
{ cmd: '/trust', desc: 'Security level & policies', category: 'Trust' },
|
|
20
|
+
{ cmd: '/trust <level>', desc: 'Set level (careful/balanced/yolo)', category: 'Trust' },
|
|
21
|
+
{ cmd: '/trust log', desc: 'Audit log', category: 'Trust' },
|
|
22
|
+
{ cmd: '/dry', desc: 'Toggle dry-run mode', category: 'Trust' },
|
|
23
|
+
{ cmd: '/receipt', desc: 'Last execution receipt', category: 'Trust' },
|
|
24
|
+
{ cmd: '/replay', desc: 'Replay current session', category: 'Trust' },
|
|
25
|
+
|
|
26
|
+
// ── Continuity ──────────────────────────────────────────────────────────────
|
|
27
|
+
{ cmd: '/where', desc: 'Show current mode', category: 'Continuity' },
|
|
28
|
+
{ cmd: '/handoff', desc: 'Sync + context summary', category: 'Continuity' },
|
|
29
|
+
{ cmd: '/sync', desc: 'Sync with remote', category: 'Continuity' },
|
|
30
|
+
|
|
31
|
+
// ── Skills ──────────────────────────────────────────────────────────────────
|
|
32
|
+
{ cmd: '/skills', desc: 'List learned skills', category: 'Skills' },
|
|
33
|
+
{ cmd: '/teach <name>', desc: 'Create skill from conversation', category: 'Skills' },
|
|
34
|
+
{ cmd: '/improve <name>', desc: 'Improve a skill', category: 'Skills' },
|
|
35
|
+
|
|
36
|
+
// ── Session ─────────────────────────────────────────────────────────────────
|
|
37
|
+
{ cmd: '/clear', desc: 'Reset conversation', category: 'Session' },
|
|
38
|
+
{ cmd: '/model', desc: 'Show/change model', category: 'Session' },
|
|
39
|
+
{ cmd: '/cost', desc: 'Token usage & cost', category: 'Session' },
|
|
40
|
+
{ cmd: '/compact', desc: 'Compress conversation', category: 'Session' },
|
|
41
|
+
{ cmd: '/sessions', desc: 'List all sessions', category: 'Session' },
|
|
42
|
+
{ cmd: '/history', desc: 'Show conversation length', category: 'Session' },
|
|
43
|
+
|
|
44
|
+
// ── Memory ──────────────────────────────────────────────────────────────────
|
|
45
|
+
{ cmd: '/remember <text>', desc: 'Save to memory', category: 'Memory' },
|
|
46
|
+
{ cmd: '/recall <query>', desc: 'Search memories', category: 'Memory' },
|
|
47
|
+
{ cmd: '/memories', desc: 'List all memories', category: 'Memory' },
|
|
48
|
+
{ cmd: '/forget <key>', desc: 'Delete memory', category: 'Memory' },
|
|
49
|
+
|
|
50
|
+
// ── Agents ──────────────────────────────────────────────────────────────────
|
|
51
|
+
{ cmd: '/agents', desc: 'List sub-agents', category: 'Agents' },
|
|
52
|
+
{ cmd: '/agent <id>', desc: 'Agent details', category: 'Agents' },
|
|
53
|
+
{ cmd: '/kill <id>', desc: 'Kill sub-agent', category: 'Agents' },
|
|
54
|
+
|
|
55
|
+
// ── MCP ─────────────────────────────────────────────────────────────────────
|
|
56
|
+
{ cmd: '/mcp', desc: 'MCP server management', category: 'MCP' },
|
|
57
|
+
{ cmd: '/mcp list', desc: 'List MCP servers', category: 'MCP' },
|
|
58
|
+
{ cmd: '/mcp connect', desc: 'Connect MCP server', category: 'MCP' },
|
|
59
|
+
{ cmd: '/mcp disconnect', desc: 'Disconnect MCP server', category: 'MCP' },
|
|
60
|
+
{ cmd: '/mcp reload', desc: 'Reload MCP servers', category: 'MCP' },
|
|
61
|
+
{ cmd: '/mcp config', desc: 'Show MCP config', category: 'MCP' },
|
|
62
|
+
|
|
63
|
+
// ── Permissions & Audit ─────────────────────────────────────────────────────
|
|
64
|
+
{ cmd: '/permissions', desc: 'Show permission policies', category: 'Trust' },
|
|
65
|
+
{ cmd: '/permit <tool> <level>', desc: 'Change tool policy (auto|notify|approve)', category: 'Trust' },
|
|
66
|
+
{ cmd: '/audit', desc: 'Show recent audit events', category: 'Trust' },
|
|
67
|
+
|
|
68
|
+
// ── Quick aliases ───────────────────────────────────────────────────────────
|
|
69
|
+
{ cmd: '/o', desc: 'Overview (alias for /ws status)', category: 'Quick' },
|
|
70
|
+
{ cmd: '/w', desc: 'Workstreams list (alias)', category: 'Quick' },
|
|
71
|
+
{ cmd: '/a', desc: 'Agents list (alias)', category: 'Quick' },
|
|
72
|
+
{ cmd: '/m', desc: 'Memories (alias)', category: 'Quick' },
|
|
73
|
+
{ cmd: '/t', desc: 'Timeline / recent actions (alias)', category: 'Quick' },
|
|
74
|
+
{ cmd: '/s', desc: 'Sync status (alias)', category: 'Quick' },
|
|
75
|
+
{ cmd: '/d', desc: 'Dry-run toggle (alias)', category: 'Quick' },
|
|
76
|
+
|
|
77
|
+
// ── Meta ─────────────────────────────────────────────────────────────────────
|
|
78
|
+
{ cmd: '/help', desc: 'Show all commands', category: 'Meta' },
|
|
79
|
+
{ cmd: '/quit', desc: 'Exit wispy', category: 'Meta' },
|
|
80
|
+
{ cmd: '/exit', desc: 'Exit wispy (alias)', category: 'Meta' },
|
|
81
|
+
{ cmd: '/provider', desc: 'Show current provider & model', category: 'Meta' },
|
|
82
|
+
{ cmd: '/overview', desc: 'Director view of all workstreams', category: 'Meta' },
|
|
83
|
+
{ cmd: '/search <keyword>', desc: 'Search across workstreams', category: 'Meta' },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Returns COMMANDS merged with dynamically loaded skills.
|
|
88
|
+
* skillManager is optional — if null, returns base COMMANDS.
|
|
89
|
+
*/
|
|
90
|
+
export function getCommandsWithSkills(skillManager) {
|
|
91
|
+
const skills = skillManager?.list?.() ?? [];
|
|
92
|
+
return [
|
|
93
|
+
...COMMANDS,
|
|
94
|
+
...skills.map(s => ({
|
|
95
|
+
cmd: `/${s.name}`,
|
|
96
|
+
desc: s.description ?? 'Learned skill',
|
|
97
|
+
category: 'Skill',
|
|
98
|
+
})),
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Filter commands that start with the given prefix.
|
|
104
|
+
* Returns up to `limit` results.
|
|
105
|
+
*/
|
|
106
|
+
export function filterCommands(prefix, commands = COMMANDS, limit = 10) {
|
|
107
|
+
if (!prefix) return [];
|
|
108
|
+
const lower = prefix.toLowerCase();
|
|
109
|
+
return commands
|
|
110
|
+
.filter(c => c.cmd.toLowerCase().startsWith(lower))
|
|
111
|
+
.slice(0, limit);
|
|
112
|
+
}
|
package/lib/wispy-repl.mjs
CHANGED
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { createInterface } from "node:readline";
|
|
16
|
-
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
16
|
+
import { appendFile, mkdir, readFile, writeFile, readdir, stat } from "node:fs/promises";
|
|
17
17
|
import { spawn as spawnProcess } from "node:child_process";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { statSync } from "node:fs";
|
|
20
20
|
|
|
21
|
+
import { COMMANDS, getCommandsWithSkills, filterCommands } from "./command-registry.mjs";
|
|
22
|
+
|
|
21
23
|
import {
|
|
22
24
|
WispyEngine,
|
|
23
25
|
loadConfig,
|
|
@@ -1084,6 +1086,136 @@ ${bold("Permissions & Audit (v1.1):")}
|
|
|
1084
1086
|
// Interactive REPL
|
|
1085
1087
|
// ---------------------------------------------------------------------------
|
|
1086
1088
|
|
|
1089
|
+
// ---------------------------------------------------------------------------
|
|
1090
|
+
// REPL autocomplete helpers
|
|
1091
|
+
// ---------------------------------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Build a combined command list for the completer.
|
|
1095
|
+
* Synchronous-safe: uses cached skills list if engine is available.
|
|
1096
|
+
*/
|
|
1097
|
+
let _cachedCommands = null;
|
|
1098
|
+
function getCachedCommands(engine) {
|
|
1099
|
+
if (_cachedCommands) return _cachedCommands;
|
|
1100
|
+
try {
|
|
1101
|
+
const skills = engine?.skills?.listSync?.() ?? [];
|
|
1102
|
+
_cachedCommands = getCommandsWithSkills({ list: () => skills });
|
|
1103
|
+
} catch {
|
|
1104
|
+
_cachedCommands = COMMANDS;
|
|
1105
|
+
}
|
|
1106
|
+
return _cachedCommands;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Path completer: expands file-system paths starting with / ~ or ./
|
|
1111
|
+
*/
|
|
1112
|
+
async function pathCompleter(partial) {
|
|
1113
|
+
try {
|
|
1114
|
+
let dir, prefix;
|
|
1115
|
+
if (partial === '' || partial === '.') {
|
|
1116
|
+
dir = process.cwd(); prefix = partial;
|
|
1117
|
+
} else if (partial.startsWith('~/')) {
|
|
1118
|
+
const expanded = path.join(os.homedir(), partial.slice(2));
|
|
1119
|
+
dir = path.dirname(expanded);
|
|
1120
|
+
prefix = partial;
|
|
1121
|
+
} else {
|
|
1122
|
+
dir = path.dirname(partial) || '.';
|
|
1123
|
+
if (dir === '.') dir = process.cwd();
|
|
1124
|
+
prefix = partial;
|
|
1125
|
+
}
|
|
1126
|
+
const base = path.basename(partial);
|
|
1127
|
+
const entries = await readdir(dir).catch(() => []);
|
|
1128
|
+
const hits = entries
|
|
1129
|
+
.filter(e => e.startsWith(base))
|
|
1130
|
+
.map(e => {
|
|
1131
|
+
const full = path.join(dir, e);
|
|
1132
|
+
try {
|
|
1133
|
+
const s = statSync(full);
|
|
1134
|
+
return s.isDirectory() ? full + '/' : full;
|
|
1135
|
+
} catch { return full; }
|
|
1136
|
+
});
|
|
1137
|
+
return [hits, partial];
|
|
1138
|
+
} catch {
|
|
1139
|
+
return [[], partial];
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Main readline completer: slash commands + path expansion
|
|
1145
|
+
*/
|
|
1146
|
+
function makeCompleter(engine) {
|
|
1147
|
+
return function completer(line, callback) {
|
|
1148
|
+
// Slash command completion
|
|
1149
|
+
if (line.startsWith('/')) {
|
|
1150
|
+
const commands = getCachedCommands(engine);
|
|
1151
|
+
const hits = filterCommands(line, commands, 15);
|
|
1152
|
+
const completions = hits.map(c => c.cmd);
|
|
1153
|
+
// If no hits, return empty
|
|
1154
|
+
callback(null, [completions.length ? completions : [], line]);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Path completion (starts with /, ~/, ./, or contains a /)
|
|
1159
|
+
const lastWord = line.split(' ').pop() ?? '';
|
|
1160
|
+
if (lastWord.startsWith('/') || lastWord.startsWith('~/') || lastWord.startsWith('./') || lastWord.startsWith('../')) {
|
|
1161
|
+
pathCompleter(lastWord).then(([hits, orig]) => {
|
|
1162
|
+
callback(null, [hits, orig]);
|
|
1163
|
+
}).catch(() => callback(null, [[], line]));
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// No completion
|
|
1168
|
+
callback(null, [[], line]);
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Print a preview hint below the current line using ANSI escape sequences.
|
|
1174
|
+
* Clears previous hint before printing new one.
|
|
1175
|
+
*/
|
|
1176
|
+
let _lastHintLines = 0;
|
|
1177
|
+
function showCommandHint(line, engine) {
|
|
1178
|
+
if (!process.stdout.isTTY) return;
|
|
1179
|
+
// Clear previous hint
|
|
1180
|
+
if (_lastHintLines > 0) {
|
|
1181
|
+
process.stdout.write(`\x1b[${_lastHintLines}B`); // move down past hints
|
|
1182
|
+
for (let i = 0; i < _lastHintLines; i++) {
|
|
1183
|
+
process.stdout.write('\x1b[2K\x1b[A'); // clear line, move up
|
|
1184
|
+
}
|
|
1185
|
+
_lastHintLines = 0;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (!line.startsWith('/') || line.length < 2) return;
|
|
1189
|
+
|
|
1190
|
+
const commands = getCachedCommands(engine);
|
|
1191
|
+
const matches = filterCommands(line, commands, 5);
|
|
1192
|
+
if (matches.length === 0) return;
|
|
1193
|
+
|
|
1194
|
+
const hints = matches.map(c => {
|
|
1195
|
+
const cmdPart = c.cmd.padEnd(30);
|
|
1196
|
+
return ` \x1b[2m${cmdPart} — ${c.desc}\x1b[0m`;
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
// Save cursor, move to next line, print hints, restore cursor
|
|
1200
|
+
process.stdout.write('\x1b[s'); // save cursor
|
|
1201
|
+
process.stdout.write('\n');
|
|
1202
|
+
for (const h of hints) {
|
|
1203
|
+
process.stdout.write(`\x1b[2K${h}\n`);
|
|
1204
|
+
}
|
|
1205
|
+
process.stdout.write('\x1b[u'); // restore cursor
|
|
1206
|
+
_lastHintLines = hints.length;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function clearCommandHint() {
|
|
1210
|
+
if (!process.stdout.isTTY || _lastHintLines === 0) return;
|
|
1211
|
+
process.stdout.write('\x1b[s');
|
|
1212
|
+
for (let i = 0; i < _lastHintLines; i++) {
|
|
1213
|
+
process.stdout.write('\n\x1b[2K');
|
|
1214
|
+
}
|
|
1215
|
+
process.stdout.write('\x1b[u');
|
|
1216
|
+
_lastHintLines = 0;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1087
1219
|
async function runRepl(engine) {
|
|
1088
1220
|
// Initialize dry-run from env flag (set by `wispy dry "..."`)
|
|
1089
1221
|
if (process.env.WISPY_DRY_RUN === "1") {
|
|
@@ -1104,8 +1236,26 @@ async function runRepl(engine) {
|
|
|
1104
1236
|
output: process.stdout,
|
|
1105
1237
|
prompt: green("› "),
|
|
1106
1238
|
historySize: 100,
|
|
1239
|
+
completer: makeCompleter(engine),
|
|
1107
1240
|
});
|
|
1108
1241
|
|
|
1242
|
+
// Show inline hints as user types
|
|
1243
|
+
rl.on('line', () => { clearCommandHint(); });
|
|
1244
|
+
if (process.stdin.isTTY) {
|
|
1245
|
+
process.stdin.on('keypress', (_ch, key) => {
|
|
1246
|
+
if (!key) return;
|
|
1247
|
+
// Slight delay to let readline update its line buffer
|
|
1248
|
+
setImmediate(() => {
|
|
1249
|
+
const line = rl.line ?? '';
|
|
1250
|
+
if (line.startsWith('/')) {
|
|
1251
|
+
showCommandHint(line, engine);
|
|
1252
|
+
} else {
|
|
1253
|
+
clearCommandHint();
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1109
1259
|
// Dynamic prompt — shows workstream and dry-run status
|
|
1110
1260
|
function updatePrompt() {
|
|
1111
1261
|
const ws = ACTIVE_WORKSTREAM !== "default" ? `${cyan(ACTIVE_WORKSTREAM)} ` : "";
|
package/lib/wispy-tui.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import { render, Box, Text, useApp, Newline, useInput, useStdout } from "ink";
|
|
|
14
14
|
import Spinner from "ink-spinner";
|
|
15
15
|
import TextInput from "ink-text-input";
|
|
16
16
|
|
|
17
|
+
import { COMMANDS, filterCommands } from "./command-registry.mjs";
|
|
18
|
+
|
|
17
19
|
import os from "node:os";
|
|
18
20
|
import path from "node:path";
|
|
19
21
|
import { readFile, writeFile, readdir, stat, mkdir } from "node:fs/promises";
|
|
@@ -661,6 +663,71 @@ function HelpOverlay({ onClose }) {
|
|
|
661
663
|
);
|
|
662
664
|
}
|
|
663
665
|
|
|
666
|
+
// ─── Command Palette (autocomplete dropdown) ──────────────────────────────────
|
|
667
|
+
|
|
668
|
+
function CommandPalette({ query, onSelect, onDismiss }) {
|
|
669
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
670
|
+
|
|
671
|
+
const matches = filterCommands(query, COMMANDS, 8);
|
|
672
|
+
|
|
673
|
+
// Reset selection when query changes
|
|
674
|
+
useEffect(() => { setSelectedIdx(0); }, [query]);
|
|
675
|
+
|
|
676
|
+
useInput((input, key) => {
|
|
677
|
+
if (matches.length === 0) return;
|
|
678
|
+
if (key.upArrow) {
|
|
679
|
+
setSelectedIdx(i => Math.max(0, i - 1));
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (key.downArrow) {
|
|
683
|
+
setSelectedIdx(i => Math.min(matches.length - 1, i + 1));
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (key.return) {
|
|
687
|
+
onSelect?.(matches[selectedIdx]?.cmd ?? query);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (key.escape || (key.ctrl && input === 'c')) {
|
|
691
|
+
onDismiss?.();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
if (matches.length === 0) return null;
|
|
697
|
+
|
|
698
|
+
const maxCmdLen = Math.max(...matches.map(m => m.cmd.length));
|
|
699
|
+
const width = Math.min(60, maxCmdLen + 30);
|
|
700
|
+
|
|
701
|
+
return React.createElement(
|
|
702
|
+
Box, {
|
|
703
|
+
flexDirection: 'column',
|
|
704
|
+
borderStyle: 'single',
|
|
705
|
+
borderColor: 'cyan',
|
|
706
|
+
paddingX: 1,
|
|
707
|
+
marginBottom: 0,
|
|
708
|
+
},
|
|
709
|
+
React.createElement(
|
|
710
|
+
Box, { paddingX: 1 },
|
|
711
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, '─ Commands '),
|
|
712
|
+
React.createElement(Text, { dimColor: true }, '↑↓ navigate · Enter select · Esc dismiss'),
|
|
713
|
+
),
|
|
714
|
+
...matches.map((cmd, i) => {
|
|
715
|
+
const isActive = i === selectedIdx;
|
|
716
|
+
const cmdStr = cmd.cmd.padEnd(maxCmdLen);
|
|
717
|
+
return React.createElement(
|
|
718
|
+
Box, { key: cmd.cmd },
|
|
719
|
+
isActive
|
|
720
|
+
? React.createElement(Text, { color: 'black', backgroundColor: 'cyan' },
|
|
721
|
+
` ${cmdStr} ${cmd.desc} `)
|
|
722
|
+
: React.createElement(Box, {},
|
|
723
|
+
React.createElement(Text, { color: 'cyan' }, ` ${cmd.cmd}`),
|
|
724
|
+
React.createElement(Text, { dimColor: true }, ' '.repeat(Math.max(1, maxCmdLen - cmd.cmd.length + 2)) + cmd.desc),
|
|
725
|
+
),
|
|
726
|
+
);
|
|
727
|
+
}),
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
664
731
|
// ─── Input Area ───────────────────────────────────────────────────────────────
|
|
665
732
|
|
|
666
733
|
function InputArea({ value, onChange, onSubmit, loading, workstream, view }) {
|
|
@@ -817,6 +884,9 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
|
|
|
817
884
|
const [inputValue, setInputValue] = useState("");
|
|
818
885
|
const [loading, setLoading] = useState(false);
|
|
819
886
|
|
|
887
|
+
// Command palette (autocomplete)
|
|
888
|
+
const [showPalette, setShowPalette] = useState(false);
|
|
889
|
+
|
|
820
890
|
// Engine stats
|
|
821
891
|
const [model, setModel] = useState(engine.model ?? "?");
|
|
822
892
|
const [provider, setProvider] = useState(engine.provider ?? "?");
|
|
@@ -1063,12 +1133,34 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
|
|
|
1063
1133
|
}
|
|
1064
1134
|
}, [pendingApproval]);
|
|
1065
1135
|
|
|
1136
|
+
// ── Palette visibility: show when typing slash commands ──
|
|
1137
|
+
const handleInputChange = useCallback((val) => {
|
|
1138
|
+
setInputValue(val);
|
|
1139
|
+
if (val.startsWith('/') && val.length >= 2) {
|
|
1140
|
+
setShowPalette(true);
|
|
1141
|
+
} else {
|
|
1142
|
+
setShowPalette(false);
|
|
1143
|
+
}
|
|
1144
|
+
}, []);
|
|
1145
|
+
|
|
1146
|
+
const handlePaletteSelect = useCallback((cmd) => {
|
|
1147
|
+
// Fill in base command (strip template args like <name>)
|
|
1148
|
+
const base = cmd.replace(/<[^>]+>/g, '').trimEnd();
|
|
1149
|
+
setInputValue(base);
|
|
1150
|
+
setShowPalette(false);
|
|
1151
|
+
}, []);
|
|
1152
|
+
|
|
1153
|
+
const handlePaletteDismiss = useCallback(() => {
|
|
1154
|
+
setShowPalette(false);
|
|
1155
|
+
}, []);
|
|
1156
|
+
|
|
1066
1157
|
// ── Message submit ──
|
|
1067
1158
|
const handleSubmit = useCallback(async (value) => {
|
|
1068
1159
|
if (pendingApproval) return;
|
|
1069
1160
|
const input = value.trim();
|
|
1070
1161
|
if (!input || loading) return;
|
|
1071
1162
|
setInputValue("");
|
|
1163
|
+
setShowPalette(false);
|
|
1072
1164
|
|
|
1073
1165
|
// Switch to chat if not there
|
|
1074
1166
|
if (view !== "chat") setView("chat");
|
|
@@ -1261,11 +1353,20 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
|
|
|
1261
1353
|
? React.createElement(TimelineBar, { events: timeline })
|
|
1262
1354
|
: null,
|
|
1263
1355
|
|
|
1356
|
+
// Command palette (autocomplete) — shown above input when typing /command
|
|
1357
|
+
!showHelp && showPalette && inputValue.startsWith('/')
|
|
1358
|
+
? React.createElement(CommandPalette, {
|
|
1359
|
+
query: inputValue,
|
|
1360
|
+
onSelect: handlePaletteSelect,
|
|
1361
|
+
onDismiss: handlePaletteDismiss,
|
|
1362
|
+
})
|
|
1363
|
+
: null,
|
|
1364
|
+
|
|
1264
1365
|
// Input area (always at bottom; hidden when help shown)
|
|
1265
1366
|
!showHelp
|
|
1266
1367
|
? React.createElement(InputArea, {
|
|
1267
1368
|
value: inputValue,
|
|
1268
|
-
onChange:
|
|
1369
|
+
onChange: handleInputChange,
|
|
1269
1370
|
onSubmit: handleSubmit,
|
|
1270
1371
|
loading,
|
|
1271
1372
|
workstream: activeWorkstream,
|