wispy-cli 2.7.17 → 2.7.18

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 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
+ }
@@ -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
- console.log(dim(` Get a key at: ${info.signupUrl}`));
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.17",
3
+ "version": "2.7.18",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",