wispy-cli 2.7.17 → 2.7.19
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 +188 -0
- package/core/oauth-flows.mjs +102 -0
- package/core/onboarding.mjs +47 -2
- package/lib/wispy-tui.mjs +433 -254
- package/package.json +1 -1
package/bin/wispy.mjs
CHANGED
|
@@ -133,6 +133,9 @@ Usage:
|
|
|
133
133
|
wispy doctor Check system health
|
|
134
134
|
wispy browser [tabs|attach|navigate|screenshot|doctor]
|
|
135
135
|
Browser control via local-browser-bridge
|
|
136
|
+
wispy auth Show auth status for all providers
|
|
137
|
+
wispy auth <provider> Re-authenticate a specific provider
|
|
138
|
+
wispy auth --reset Clear all saved credentials
|
|
136
139
|
wispy secrets [list|set|delete|get]
|
|
137
140
|
Manage encrypted secrets & API keys
|
|
138
141
|
wispy tts "<text>" Text-to-speech (OpenAI or macOS say)
|
|
@@ -240,6 +243,191 @@ if (command === "setup") {
|
|
|
240
243
|
process.exit(0);
|
|
241
244
|
}
|
|
242
245
|
|
|
246
|
+
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
if (command === "auth") {
|
|
249
|
+
try {
|
|
250
|
+
const { loadConfig, saveConfig, PROVIDERS, WISPY_DIR } = await import(join(rootDir, "core/config.mjs"));
|
|
251
|
+
const { AuthManager } = await import(join(rootDir, "core/auth.mjs"));
|
|
252
|
+
const { readdir, unlink } = await import("node:fs/promises");
|
|
253
|
+
const { existsSync } = await import("node:fs");
|
|
254
|
+
const path = await import("node:path");
|
|
255
|
+
|
|
256
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
257
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
258
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
259
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
260
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
261
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
262
|
+
|
|
263
|
+
const sub = args[1];
|
|
264
|
+
|
|
265
|
+
// ── wispy auth --reset ───────────────────────────────────────────────────
|
|
266
|
+
if (sub === "--reset") {
|
|
267
|
+
const { confirm } = await import("@inquirer/prompts");
|
|
268
|
+
const yes = await confirm({
|
|
269
|
+
message: "Clear ALL saved credentials (API keys + tokens)?",
|
|
270
|
+
default: false,
|
|
271
|
+
});
|
|
272
|
+
if (yes) {
|
|
273
|
+
const config = await loadConfig();
|
|
274
|
+
delete config.providers;
|
|
275
|
+
delete config.apiKey;
|
|
276
|
+
delete config.provider;
|
|
277
|
+
await saveConfig(config);
|
|
278
|
+
|
|
279
|
+
// Remove auth token files
|
|
280
|
+
const authDir = path.join(WISPY_DIR, "auth");
|
|
281
|
+
if (existsSync(authDir)) {
|
|
282
|
+
const files = await readdir(authDir);
|
|
283
|
+
for (const f of files) {
|
|
284
|
+
await unlink(path.join(authDir, f)).catch(() => {});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
console.log(green("✓ All credentials cleared."));
|
|
288
|
+
} else {
|
|
289
|
+
console.log(dim("Cancelled."));
|
|
290
|
+
}
|
|
291
|
+
process.exit(0);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const config = await loadConfig();
|
|
295
|
+
const authMgr = new AuthManager(WISPY_DIR);
|
|
296
|
+
|
|
297
|
+
// ── wispy auth <provider> ────────────────────────────────────────────────
|
|
298
|
+
if (sub && !sub.startsWith("-")) {
|
|
299
|
+
const provKey = sub;
|
|
300
|
+
const providerInfo = PROVIDERS?.[provKey];
|
|
301
|
+
if (!providerInfo) {
|
|
302
|
+
console.error(`Unknown provider: ${provKey}`);
|
|
303
|
+
console.log(dim("Run `wispy auth` (no args) to see configured providers."));
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
console.log(`\n Re-authenticating ${bold(providerInfo.label ?? provKey)}...\n`);
|
|
308
|
+
|
|
309
|
+
if (provKey === "github-copilot") {
|
|
310
|
+
const { githubDeviceFlow } = await import(join(rootDir, "core/oauth-flows.mjs"));
|
|
311
|
+
const result = await githubDeviceFlow();
|
|
312
|
+
if (result?.access_token) {
|
|
313
|
+
await authMgr.saveToken("github-copilot", {
|
|
314
|
+
type: "oauth",
|
|
315
|
+
accessToken: result.access_token,
|
|
316
|
+
provider: "github-copilot",
|
|
317
|
+
});
|
|
318
|
+
if (!config.providers) config.providers = {};
|
|
319
|
+
config.providers["github-copilot"] = { model: providerInfo.defaultModel ?? "gpt-4o", auth: "oauth" };
|
|
320
|
+
await saveConfig(config);
|
|
321
|
+
console.log(green(" ✅ GitHub Copilot authenticated!"));
|
|
322
|
+
} else {
|
|
323
|
+
console.error(red(" ✗ Authentication failed"));
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
// Standard API key re-auth
|
|
328
|
+
const { openUrl } = await import(join(rootDir, "core/onboarding.mjs"));
|
|
329
|
+
const { select, password } = await import("@inquirer/prompts");
|
|
330
|
+
|
|
331
|
+
if (providerInfo.signupUrl) {
|
|
332
|
+
try {
|
|
333
|
+
const choice = await select({
|
|
334
|
+
message: `How would you like to authenticate with ${providerInfo.label ?? provKey}?`,
|
|
335
|
+
choices: [
|
|
336
|
+
{ name: `Open ${providerInfo.label ?? provKey} in browser → paste API key`, value: "browser" },
|
|
337
|
+
{ name: "Paste API key directly", value: "paste" },
|
|
338
|
+
{ name: "Cancel", value: "cancel" },
|
|
339
|
+
],
|
|
340
|
+
});
|
|
341
|
+
if (choice === "cancel") { console.log(dim("Cancelled.")); process.exit(0); }
|
|
342
|
+
if (choice === "browser") {
|
|
343
|
+
console.log(`\n Opening ${providerInfo.label ?? provKey} in your browser...`);
|
|
344
|
+
await openUrl(providerInfo.signupUrl);
|
|
345
|
+
console.log(dim(` → ${providerInfo.signupUrl}`));
|
|
346
|
+
console.log(dim(` Copy your API key and paste it below.\n`));
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
// non-interactive, fall through
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const key = await password({
|
|
354
|
+
message: ` 🔑 ${providerInfo.label ?? provKey} API key:`,
|
|
355
|
+
mask: "*",
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (!key?.trim()) { console.log(dim(" No key provided. Exiting.")); process.exit(0); }
|
|
359
|
+
|
|
360
|
+
process.stdout.write(" Validating...");
|
|
361
|
+
const { validateApiKey } = await import(join(rootDir, "core/onboarding.mjs")).catch(() => ({ validateApiKey: null }));
|
|
362
|
+
let validated = false;
|
|
363
|
+
if (validateApiKey) {
|
|
364
|
+
const result = await validateApiKey(provKey, key.trim());
|
|
365
|
+
if (result.ok) {
|
|
366
|
+
console.log(green(" ✅ Connected!"));
|
|
367
|
+
validated = true;
|
|
368
|
+
} else {
|
|
369
|
+
console.log(yellow(` ⚠ ${result.error ?? "Could not validate"} — saving anyway.`));
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
console.log(dim(" (validation skipped)"));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!config.providers) config.providers = {};
|
|
376
|
+
config.providers[provKey] = {
|
|
377
|
+
...(config.providers[provKey] ?? {}),
|
|
378
|
+
apiKey: key.trim(),
|
|
379
|
+
};
|
|
380
|
+
await saveConfig(config);
|
|
381
|
+
console.log(green(` ✅ ${providerInfo.label ?? provKey} credentials updated.`));
|
|
382
|
+
}
|
|
383
|
+
process.exit(0);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── wispy auth (status) ──────────────────────────────────────────────────
|
|
387
|
+
console.log(`\n ${bold("Auth Status")}\n`);
|
|
388
|
+
|
|
389
|
+
const configuredProviders = config.providers ? Object.keys(config.providers) : [];
|
|
390
|
+
if (config.provider && !configuredProviders.includes(config.provider)) {
|
|
391
|
+
configuredProviders.push(config.provider);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (configuredProviders.length === 0) {
|
|
395
|
+
console.log(dim(" No providers configured. Run `wispy setup` to get started.\n"));
|
|
396
|
+
} else {
|
|
397
|
+
for (const provKey of configuredProviders) {
|
|
398
|
+
const provInfo = PROVIDERS?.[provKey];
|
|
399
|
+
const label = provInfo?.label ?? provKey;
|
|
400
|
+
const provConfig = config.providers?.[provKey] ?? {};
|
|
401
|
+
const hasKey = !!(provConfig.apiKey || provKey === "ollama" || provKey === "vllm");
|
|
402
|
+
const isOAuth = provConfig.auth === "oauth";
|
|
403
|
+
const token = authMgr.loadToken(provKey);
|
|
404
|
+
const hasToken = !!token;
|
|
405
|
+
|
|
406
|
+
let status;
|
|
407
|
+
if (isOAuth || hasToken) {
|
|
408
|
+
const expired = authMgr.isExpired?.(provKey) ?? false;
|
|
409
|
+
status = expired ? yellow("⚠ token expired") : green("✅ OAuth/token");
|
|
410
|
+
} else if (hasKey) {
|
|
411
|
+
status = green("✅ API key set");
|
|
412
|
+
} else {
|
|
413
|
+
status = red("✗ no credentials");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const model = provConfig.model ?? provInfo?.defaultModel ?? "";
|
|
417
|
+
console.log(` ${cyan(label.padEnd(30))} ${status}${model ? dim(` (${model})`) : ""}`);
|
|
418
|
+
}
|
|
419
|
+
console.log("");
|
|
420
|
+
console.log(dim(" Run `wispy auth <provider>` to re-authenticate a specific provider."));
|
|
421
|
+
console.log(dim(" Run `wispy auth --reset` to clear all credentials.\n"));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
} catch (err) {
|
|
425
|
+
console.error("Auth error:", err.message);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
process.exit(0);
|
|
429
|
+
}
|
|
430
|
+
|
|
243
431
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
244
432
|
|
|
245
433
|
if (command === "config") {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/oauth-flows.mjs — OAuth/device flow handlers for various providers
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - startCallbackServer() — localhost HTTP server for OAuth redirect flows
|
|
6
|
+
* - githubDeviceFlow() — re-exported from auth.mjs for convenience
|
|
7
|
+
* - browserKeyFlow() — generic "open browser + wait for paste" flow
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
|
|
12
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// Localhost callback server for OAuth redirect flows
|
|
14
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Starts a temporary HTTP server on localhost that waits for an OAuth redirect.
|
|
18
|
+
* Resolves with { code, token, params } when the browser hits the callback URL.
|
|
19
|
+
* Rejects after 120 seconds if no callback is received.
|
|
20
|
+
*
|
|
21
|
+
* @param {number} [port=9876] - Port to listen on
|
|
22
|
+
* @returns {Promise<{ code: string|null, token: string|null, params: Record<string,string> }>}
|
|
23
|
+
*/
|
|
24
|
+
export async function startCallbackServer(port = 9876) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
let resolved = false;
|
|
27
|
+
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
30
|
+
const code = url.searchParams.get("code");
|
|
31
|
+
const token =
|
|
32
|
+
url.searchParams.get("token") || url.searchParams.get("key");
|
|
33
|
+
|
|
34
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
35
|
+
res.end(`
|
|
36
|
+
<html><body style="font-family:system-ui;text-align:center;padding:60px">
|
|
37
|
+
<h2>✓ Wispy connected!</h2>
|
|
38
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
39
|
+
<script>window.close()</script>
|
|
40
|
+
</body></html>
|
|
41
|
+
`);
|
|
42
|
+
|
|
43
|
+
if (!resolved) {
|
|
44
|
+
resolved = true;
|
|
45
|
+
server.close();
|
|
46
|
+
resolve({ code, token, params: Object.fromEntries(url.searchParams) });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
server.listen(port, "127.0.0.1");
|
|
51
|
+
|
|
52
|
+
// Timeout after 120 seconds
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
if (!resolved) {
|
|
55
|
+
resolved = true;
|
|
56
|
+
server.close();
|
|
57
|
+
reject(new Error("Authentication timed out (120s)"));
|
|
58
|
+
}
|
|
59
|
+
}, 120_000);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
// GitHub OAuth device flow (re-export from auth.mjs)
|
|
65
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export { githubDeviceFlow } from "./auth.mjs";
|
|
68
|
+
|
|
69
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// Generic "open browser, wait for paste" flow with optional validation
|
|
71
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Opens the provider's signup URL in the browser, then prompts the user to
|
|
75
|
+
* paste their API key. Optionally validates the key via validateFn.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ label: string, signupUrl?: string }} providerInfo
|
|
78
|
+
* @param {((key: string) => Promise<{ ok: boolean, error?: string, model?: string }>)|null} [validateFn]
|
|
79
|
+
* @returns {Promise<{ valid: boolean, key?: string, error?: string }>}
|
|
80
|
+
*/
|
|
81
|
+
export async function browserKeyFlow(providerInfo, validateFn = null) {
|
|
82
|
+
const { openUrl } = await import("./onboarding.mjs");
|
|
83
|
+
|
|
84
|
+
if (providerInfo.signupUrl) {
|
|
85
|
+
await openUrl(providerInfo.signupUrl);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { password } = await import("@inquirer/prompts");
|
|
89
|
+
const key = await password({
|
|
90
|
+
message: ` Paste your ${providerInfo.label} API key:`,
|
|
91
|
+
mask: "*",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!key?.trim()) return { valid: false, error: "No key provided" };
|
|
95
|
+
|
|
96
|
+
if (validateFn) {
|
|
97
|
+
const result = await validateFn(key.trim());
|
|
98
|
+
return { ...result, key: key.trim() };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { valid: true, key: key.trim() };
|
|
102
|
+
}
|
package/core/onboarding.mjs
CHANGED
|
@@ -50,6 +50,24 @@ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
|
50
50
|
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
51
51
|
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
52
52
|
|
|
53
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
// openUrl — cross-platform browser opener
|
|
55
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export async function openUrl(url) {
|
|
58
|
+
const { exec } = await import("node:child_process");
|
|
59
|
+
const { promisify } = await import("node:util");
|
|
60
|
+
const run = promisify(exec);
|
|
61
|
+
try {
|
|
62
|
+
if (process.platform === "darwin") await run(`open "${url}"`);
|
|
63
|
+
else if (process.platform === "linux") await run(`xdg-open "${url}"`);
|
|
64
|
+
else if (process.platform === "win32") await run(`start "${url}"`);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
54
72
|
// Provider registry (display order for wizard)
|
|
55
73
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -115,7 +133,7 @@ const SECURITY_LEVELS = {
|
|
|
115
133
|
// API key validation
|
|
116
134
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
117
135
|
|
|
118
|
-
async function validateApiKey(provider, key) {
|
|
136
|
+
export async function validateApiKey(provider, key) {
|
|
119
137
|
if (provider === "ollama") return { ok: true, model: "llama3.2" };
|
|
120
138
|
try {
|
|
121
139
|
if (provider === "google") {
|
|
@@ -540,8 +558,35 @@ export class OnboardingWizard {
|
|
|
540
558
|
|
|
541
559
|
// ── Standard API key flow ────────────────────────────────────────────
|
|
542
560
|
console.log("");
|
|
561
|
+
|
|
562
|
+
// Ask user how they want to authenticate
|
|
563
|
+
let skipProvider = false;
|
|
543
564
|
if (info?.signupUrl) {
|
|
544
|
-
|
|
565
|
+
try {
|
|
566
|
+
const authChoice = await select({
|
|
567
|
+
message: `How would you like to authenticate with ${info?.label ?? provKey}?`,
|
|
568
|
+
choices: [
|
|
569
|
+
{ name: `Open ${info?.label ?? provKey} in browser → paste API key`, value: "browser" },
|
|
570
|
+
{ name: "Paste API key directly (I already have one)", value: "paste" },
|
|
571
|
+
{ name: "Skip this provider", value: "skip" },
|
|
572
|
+
],
|
|
573
|
+
});
|
|
574
|
+
if (authChoice === "browser") {
|
|
575
|
+
console.log(`\n Opening ${info.label} in your browser...`);
|
|
576
|
+
await openUrl(info.signupUrl);
|
|
577
|
+
console.log(dim(` → ${info.signupUrl}`));
|
|
578
|
+
console.log(dim(` Copy your API key and paste it below.\n`));
|
|
579
|
+
} else if (authChoice === "skip") {
|
|
580
|
+
skipProvider = true;
|
|
581
|
+
}
|
|
582
|
+
} catch {
|
|
583
|
+
// if select fails (non-interactive), fall through to direct paste
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (skipProvider) {
|
|
588
|
+
console.log(dim(` Skipping ${provKey}.`));
|
|
589
|
+
continue;
|
|
545
590
|
}
|
|
546
591
|
|
|
547
592
|
let validated = false;
|