wispy-cli 2.7.16 → 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
@@ -56,6 +56,7 @@ function hasFlag(flag) {
56
56
 
57
57
  // Parse global flags (order doesn't matter, remove from args so REPL doesn't see them)
58
58
  const globalProfile = extractFlag(["--profile", "-p"], true);
59
+ const globalWorkstream = extractFlag(["--workstream", "-w"], true);
59
60
  const globalPersonality = extractFlag(["--personality"], true);
60
61
  const globalJsonMode = hasFlag("--json");
61
62
  if (globalJsonMode) { args.splice(args.indexOf("--json"), 1); }
@@ -86,6 +87,10 @@ const imagePaths = [];
86
87
 
87
88
  // Expose for submodules via env
88
89
  if (globalProfile) process.env.WISPY_PROFILE = globalProfile;
90
+ // Validate workstream: ignore flag-like values (e.g. --invalid-arg)
91
+ if (globalWorkstream && !globalWorkstream.startsWith("-")) {
92
+ process.env.WISPY_WORKSTREAM = globalWorkstream;
93
+ }
89
94
  if (globalPersonality) process.env.WISPY_PERSONALITY = globalPersonality;
90
95
  if (imagePaths.length > 0) process.env.WISPY_IMAGES = JSON.stringify(imagePaths);
91
96
  if (globalSystemPrompt) process.env.WISPY_SYSTEM_PROMPT = globalSystemPrompt;
@@ -128,6 +133,9 @@ Usage:
128
133
  wispy doctor Check system health
129
134
  wispy browser [tabs|attach|navigate|screenshot|doctor]
130
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
131
139
  wispy secrets [list|set|delete|get]
132
140
  Manage encrypted secrets & API keys
133
141
  wispy tts "<text>" Text-to-speech (OpenAI or macOS say)
@@ -235,6 +243,191 @@ if (command === "setup") {
235
243
  process.exit(0);
236
244
  }
237
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
+
238
431
  // ── Config ────────────────────────────────────────────────────────────────────
239
432
 
240
433
  if (command === "config") {
package/core/engine.mjs CHANGED
@@ -96,10 +96,14 @@ export class WispyEngine {
96
96
  this._workMdContent = null;
97
97
  this._workMdLoaded = false;
98
98
  this._workMdPath = null;
99
- this._activeWorkstream = config.workstream
100
- ?? process.env.WISPY_WORKSTREAM
101
- ?? process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream"))
102
- ?? "default";
99
+ {
100
+ const _ws = config.workstream
101
+ ?? process.env.WISPY_WORKSTREAM
102
+ ?? process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream"))
103
+ ?? "default";
104
+ // Sanitize: never use a flag-like value as workstream name
105
+ this._activeWorkstream = (_ws && !_ws.startsWith("-")) ? _ws : "default";
106
+ }
103
107
  // Personality: from config, or null (use default Wispy personality)
104
108
  this._personality = config.personality ?? null;
105
109
  // System prompt overrides from 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;
@@ -29,9 +29,11 @@ async function readJsonOr(filePath, fallback = null) {
29
29
 
30
30
  async function getActiveWorkstream() {
31
31
  const envWs = process.env.WISPY_WORKSTREAM;
32
- if (envWs) return envWs;
32
+ if (envWs && !envWs.startsWith("-")) return envWs;
33
33
  const cfg = await readJsonOr(CONFIG_PATH, {});
34
- return cfg.workstream ?? "default";
34
+ const ws = cfg.workstream ?? "default";
35
+ // Sanitize: never return a flag-like value (e.g. "--invalid-arg")
36
+ return (ws && !ws.startsWith("-")) ? ws : "default";
35
37
  }
36
38
 
37
39
  async function getProviderLabel(cfg) {
@@ -62,8 +62,10 @@ function box(lines, { padding = 1 } = {}) {
62
62
  // ---------------------------------------------------------------------------
63
63
 
64
64
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
65
- const ACTIVE_WORKSTREAM = process.env.WISPY_WORKSTREAM ??
65
+ const _rawWorkstream = process.env.WISPY_WORKSTREAM ??
66
66
  process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream")) ?? "default";
67
+ // Validate: ignore flag-like values to avoid e.g. "--invalid-arg" becoming workstream
68
+ const ACTIVE_WORKSTREAM = (_rawWorkstream && !_rawWorkstream.startsWith("-")) ? _rawWorkstream : "default";
67
69
  const HISTORY_FILE = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.json`);
68
70
 
69
71
  async function readFileOr(filePath, fallback = null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.16",
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",
@@ -59,7 +59,8 @@
59
59
  "scripts": {
60
60
  "test": "node --test --test-force-exit --test-timeout=15000 tests/*.test.mjs",
61
61
  "test:basic": "node --test --test-force-exit test/basic.test.mjs",
62
- "test:verbose": "node --test --test-force-exit --test-timeout=15000 --test-reporter=spec tests/*.test.mjs"
62
+ "test:verbose": "node --test --test-force-exit --test-timeout=15000 --test-reporter=spec tests/*.test.mjs",
63
+ "postpublish": "npm install -g wispy-cli@latest || true"
63
64
  },
64
65
  "dependencies": {
65
66
  "@inquirer/prompts": "^8.3.2",