wispy-cli 2.7.9 → 2.7.11

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
@@ -49,6 +49,11 @@ Usage:
49
49
  Manage configuration
50
50
  wispy model Show or change AI model
51
51
  wispy doctor Check system health
52
+ wispy browser [tabs|attach|navigate|screenshot|doctor]
53
+ Browser control via local-browser-bridge
54
+ wispy secrets [list|set|delete|get]
55
+ Manage encrypted secrets & API keys
56
+ wispy tts "<text>" Text-to-speech (OpenAI or macOS say)
52
57
  wispy trust [level|log] Security level & audit
53
58
  wispy ws [start-client|run-debug]
54
59
  WebSocket operations
@@ -161,6 +166,27 @@ if (command === "doctor") {
161
166
  for (const c of checks) {
162
167
  console.log(` ${c.ok ? "✓" : "✗"} ${c.name}`);
163
168
  }
169
+
170
+ // Browser bridge check
171
+ try {
172
+ const { BrowserBridge } = await import(join(rootDir, "core/browser.mjs"));
173
+ const bridge = new BrowserBridge();
174
+ const h = await bridge.health();
175
+ if (h?.ok || (!h?.error && h !== null)) {
176
+ const caps = await bridge.capabilities().catch(() => ({}));
177
+ const browsers = Array.isArray(caps?.browsers) ? caps.browsers : [];
178
+ const session = bridge._session;
179
+ const detail = browsers.length
180
+ ? `${browsers.join(", ")} available${session ? ", session active" : ""}`
181
+ : "running";
182
+ console.log(` ✓ Browser bridge ${detail}`);
183
+ } else {
184
+ console.log(` ✗ Browser bridge not running (start with: npx local-browser-bridge serve)`);
185
+ }
186
+ } catch {
187
+ console.log(` ✗ Browser bridge not running (start with: npx local-browser-bridge serve)`);
188
+ }
189
+
164
190
  console.log("");
165
191
 
166
192
  if (!provider?.key) {
@@ -175,6 +201,273 @@ if (command === "doctor") {
175
201
  process.exit(0);
176
202
  }
177
203
 
204
+ // ── Browser ───────────────────────────────────────────────────────────────────
205
+
206
+ if (command === "browser") {
207
+ try {
208
+ const { BrowserBridge } = await import(join(rootDir, "core/browser.mjs"));
209
+ const bridge = new BrowserBridge();
210
+ const sub = args[1];
211
+
212
+ if (!sub || sub === "status") {
213
+ // wispy browser — show status
214
+ const h = await bridge.health();
215
+ const status = bridge.status();
216
+ const running = h?.ok || (!h?.error && h !== null);
217
+ console.log(`\n Browser Bridge`);
218
+ console.log(` URL: ${bridge.baseUrl}`);
219
+ console.log(` Status: ${running ? "✓ running" : "✗ not running"}`);
220
+ if (!running) {
221
+ console.log(` Hint: npx local-browser-bridge serve`);
222
+ } else {
223
+ const caps = await bridge.capabilities().catch(() => ({}));
224
+ if (caps?.browsers?.length) {
225
+ console.log(` Browsers: ${caps.browsers.join(", ")}`);
226
+ }
227
+ }
228
+ if (status.session) {
229
+ console.log(` Session: ${status.session.id} (${status.session.browser ?? "unknown"})`);
230
+ } else {
231
+ console.log(` Session: none`);
232
+ }
233
+ console.log("");
234
+ } else if (sub === "tabs") {
235
+ // wispy browser tabs
236
+ const browser = args[2];
237
+ const result = await bridge.listTabs(browser);
238
+ if (result?.error) {
239
+ console.error(` ✗ ${result.error}`);
240
+ } else {
241
+ const tabs = result?.tabs ?? result;
242
+ console.log(`\n Open tabs:`);
243
+ if (Array.isArray(tabs)) {
244
+ for (const t of tabs) {
245
+ const title = t.title ?? t.name ?? "(no title)";
246
+ const url = t.url ?? "";
247
+ console.log(` • ${title}${url ? ` — ${url}` : ""}`);
248
+ }
249
+ } else {
250
+ console.log(JSON.stringify(tabs, null, 2));
251
+ }
252
+ console.log("");
253
+ }
254
+ } else if (sub === "attach") {
255
+ // wispy browser attach [browser]
256
+ const browser = args[2];
257
+ console.log(`\n Attaching to ${browser ?? "best available browser"}...`);
258
+ let result;
259
+ if (browser) {
260
+ result = await bridge.attach(browser);
261
+ } else {
262
+ result = await bridge.autoAttach();
263
+ }
264
+ if (result?.error) {
265
+ console.error(` ✗ ${result.error}`);
266
+ } else {
267
+ const id = result?.id ?? result?.sessionId ?? "?";
268
+ const br = result?.browser ?? browser ?? "unknown";
269
+ console.log(` ✓ Attached (${br}, session: ${id})`);
270
+ }
271
+ console.log("");
272
+ } else if (sub === "navigate") {
273
+ // wispy browser navigate <url>
274
+ const url = args[2];
275
+ if (!url) { console.error(" Usage: wispy browser navigate <url>"); process.exit(1); }
276
+ console.log(`\n Navigating to ${url}...`);
277
+ const result = await bridge.navigate(url);
278
+ if (result?.error) {
279
+ console.error(` ✗ ${result.error}`);
280
+ } else {
281
+ console.log(` ✓ Navigated`);
282
+ }
283
+ console.log("");
284
+ } else if (sub === "screenshot") {
285
+ // wispy browser screenshot
286
+ const result = await bridge.screenshot();
287
+ if (result?.error) {
288
+ console.error(` ✗ ${result.error}`);
289
+ } else {
290
+ const data = result?.screenshot ?? result?.data;
291
+ if (data) {
292
+ console.log(`\n Screenshot captured (${data.length} base64 chars)`);
293
+ // Optionally save to file
294
+ const outFile = args[2] ?? `wispy-screenshot-${Date.now()}.png`;
295
+ const buf = Buffer.from(data, "base64");
296
+ const { writeFile: wf } = await import("node:fs/promises");
297
+ await wf(outFile, buf);
298
+ console.log(` Saved to: ${outFile}`);
299
+ } else {
300
+ console.log(` Screenshot result:`, JSON.stringify(result, null, 2));
301
+ }
302
+ console.log("");
303
+ }
304
+ } else if (sub === "doctor") {
305
+ // wispy browser doctor — full diagnostics
306
+ console.log(`\n Browser Bridge Diagnostics`);
307
+ console.log(` URL: ${bridge.baseUrl}`);
308
+
309
+ const h = await bridge.health();
310
+ const running = h?.ok || (!h?.error && h !== null);
311
+ console.log(` Health: ${running ? "✓ ok" : "✗ not running"}`);
312
+
313
+ if (running) {
314
+ const caps = await bridge.capabilities();
315
+ console.log(`\n Capabilities:`);
316
+ console.log(JSON.stringify(caps, null, 2).split("\n").map(l => " " + l).join("\n"));
317
+
318
+ const browsers = Array.isArray(caps?.browsers) ? caps.browsers : [];
319
+ for (const br of browsers) {
320
+ const diag = await bridge.diagnostics(br);
321
+ console.log(`\n Diagnostics (${br}):`);
322
+ console.log(JSON.stringify(diag, null, 2).split("\n").map(l => " " + l).join("\n"));
323
+ }
324
+
325
+ const sessions = await bridge.listSessions();
326
+ console.log(`\n Sessions:`);
327
+ console.log(JSON.stringify(sessions, null, 2).split("\n").map(l => " " + l).join("\n"));
328
+ } else {
329
+ console.log(` Start with: npx local-browser-bridge serve`);
330
+ }
331
+ console.log("");
332
+ } else {
333
+ console.error(` Unknown subcommand: ${sub}`);
334
+ console.log(` Usage: wispy browser [status|tabs|attach|navigate <url>|screenshot|doctor]`);
335
+ process.exit(1);
336
+ }
337
+ } catch (err) {
338
+ console.error("Browser command error:", err.message);
339
+ process.exit(1);
340
+ }
341
+ process.exit(0);
342
+ }
343
+
344
+ // ── Secrets ───────────────────────────────────────────────────────────────────
345
+
346
+ if (command === "secrets") {
347
+ try {
348
+ const { SecretsManager } = await import(join(rootDir, "core/secrets.mjs"));
349
+ const sm = new SecretsManager();
350
+ const sub = args[1];
351
+
352
+ if (!sub || sub === "list") {
353
+ const keys = await sm.list();
354
+ if (keys.length === 0) {
355
+ console.log("No secrets stored. Use: wispy secrets set <key> <value>");
356
+ } else {
357
+ console.log(`\n Stored secrets (${keys.length}):\n`);
358
+ for (const k of keys) {
359
+ console.log(` ${k}`);
360
+ }
361
+ console.log("");
362
+ }
363
+ } else if (sub === "set") {
364
+ const key = args[2];
365
+ const value = args[3];
366
+ if (!key || value === undefined) {
367
+ console.error("Usage: wispy secrets set <key> <value>");
368
+ process.exit(1);
369
+ }
370
+ await sm.set(key, value);
371
+ console.log(`Secret '${key}' stored (encrypted).`);
372
+ } else if (sub === "delete") {
373
+ const key = args[2];
374
+ if (!key) {
375
+ console.error("Usage: wispy secrets delete <key>");
376
+ process.exit(1);
377
+ }
378
+ const result = await sm.delete(key);
379
+ if (result.success) {
380
+ console.log(`Secret '${key}' deleted.`);
381
+ } else {
382
+ console.error(`Secret '${key}' not found.`);
383
+ process.exit(1);
384
+ }
385
+ } else if (sub === "get") {
386
+ const key = args[2];
387
+ if (!key) {
388
+ console.error("Usage: wispy secrets get <key>");
389
+ process.exit(1);
390
+ }
391
+ const value = await sm.resolve(key);
392
+ if (value) {
393
+ console.log(`${key} = ***`);
394
+ console.log("(Use --reveal flag to show value — not recommended)");
395
+ if (args.includes("--reveal")) {
396
+ console.log(`Value: ${value}`);
397
+ }
398
+ } else {
399
+ console.log(`Secret '${key}' not found.`);
400
+ }
401
+ } else {
402
+ console.error(`Unknown secrets subcommand: ${sub}`);
403
+ console.log("Available: list, set <key> <value>, delete <key>, get <key>");
404
+ process.exit(1);
405
+ }
406
+ } catch (err) {
407
+ console.error("Secrets error:", err.message);
408
+ process.exit(1);
409
+ }
410
+ process.exit(0);
411
+ }
412
+
413
+ // ── TTS ───────────────────────────────────────────────────────────────────────
414
+
415
+ if (command === "tts") {
416
+ try {
417
+ const { SecretsManager } = await import(join(rootDir, "core/secrets.mjs"));
418
+ const { TTSManager } = await import(join(rootDir, "core/tts.mjs"));
419
+
420
+ const sm = new SecretsManager();
421
+ const tts = new TTSManager(sm);
422
+
423
+ // Parse args: wispy tts "text" [--voice name] [--provider openai|macos] [--play]
424
+ let textArg = args[1];
425
+ if (!textArg) {
426
+ console.error('Usage: wispy tts "text to speak" [--voice <name>] [--provider openai|macos] [--play]');
427
+ process.exit(1);
428
+ }
429
+
430
+ const voiceIdx = args.indexOf("--voice");
431
+ const providerIdx = args.indexOf("--provider");
432
+ const shouldPlay = args.includes("--play");
433
+
434
+ const voice = voiceIdx !== -1 ? args[voiceIdx + 1] : undefined;
435
+ const provider = providerIdx !== -1 ? args[providerIdx + 1] : "auto";
436
+
437
+ console.log(` Generating speech (provider: ${provider})...`);
438
+ const result = await tts.speak(textArg, { voice, provider });
439
+
440
+ if (result.error) {
441
+ console.error(` TTS Error: ${result.error}`);
442
+ process.exit(1);
443
+ }
444
+
445
+ console.log(` Provider: ${result.provider}`);
446
+ console.log(` Voice: ${result.voice}`);
447
+ console.log(` Format: ${result.format}`);
448
+ console.log(` File: ${result.path}`);
449
+
450
+ if (shouldPlay) {
451
+ const { execFile } = await import("node:child_process");
452
+ const { promisify } = await import("node:util");
453
+ const exec = promisify(execFile);
454
+ try {
455
+ if (process.platform === "darwin") {
456
+ await exec("afplay", [result.path]);
457
+ } else {
458
+ console.log(' Use --play flag only on macOS. Play manually with your audio player.');
459
+ }
460
+ } catch (playErr) {
461
+ console.error(` Playback failed: ${playErr.message}`);
462
+ }
463
+ }
464
+ } catch (err) {
465
+ console.error("TTS error:", err.message);
466
+ process.exit(1);
467
+ }
468
+ process.exit(0);
469
+ }
470
+
178
471
  // ── Trust ─────────────────────────────────────────────────────────────────────
179
472
 
180
473
  if (command === "trust") {
package/core/config.mjs CHANGED
@@ -193,17 +193,35 @@ export async function detectProvider() {
193
193
  }
194
194
  }
195
195
 
196
- // 4. macOS Keychain
197
- const keychainMap = [
198
- ["google-ai-key", "google"],
199
- ["anthropic-api-key", "anthropic"],
200
- ["openai-api-key", "openai"],
201
- ];
202
- for (const [service, provider] of keychainMap) {
203
- const key = await tryKeychainKey(service);
204
- if (key) {
205
- process.env[PROVIDERS[provider].envKeys[0]] = key;
206
- return { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
196
+ // 4. macOS Keychain (via SecretsManager for unified resolution)
197
+ try {
198
+ const { getSecretsManager } = await import("./secrets.mjs");
199
+ const sm = getSecretsManager({ wispyDir: WISPY_DIR });
200
+ const keychainProviderMap = [
201
+ ["GOOGLE_AI_KEY", "google"],
202
+ ["ANTHROPIC_API_KEY", "anthropic"],
203
+ ["OPENAI_API_KEY", "openai"],
204
+ ];
205
+ for (const [envKey, provider] of keychainProviderMap) {
206
+ const key = await sm.resolve(envKey);
207
+ if (key) {
208
+ process.env[PROVIDERS[provider].envKeys[0]] = key;
209
+ return { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
210
+ }
211
+ }
212
+ } catch {
213
+ // SecretsManager unavailable — fallback to legacy Keychain
214
+ const keychainMap = [
215
+ ["google-ai-key", "google"],
216
+ ["anthropic-api-key", "anthropic"],
217
+ ["openai-api-key", "openai"],
218
+ ];
219
+ for (const [service, provider] of keychainMap) {
220
+ const key = await tryKeychainKey(service);
221
+ if (key) {
222
+ process.env[PROVIDERS[provider].envKeys[0]] = key;
223
+ return { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
224
+ }
207
225
  }
208
226
  }
209
227
 
@@ -0,0 +1,225 @@
1
+ /**
2
+ * core/features.mjs — Feature flag system for Wispy
3
+ *
4
+ * Provides a registry of feature flags with stages (stable/experimental/development)
5
+ * and methods to enable/disable features, persisting state to config.
6
+ */
7
+
8
+ import path from "node:path";
9
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
10
+ import { WISPY_DIR, CONFIG_PATH, loadConfig, saveConfig } from "./config.mjs";
11
+
12
+ /**
13
+ * Registry of all known features.
14
+ * @type {Record<string, { stage: "stable"|"experimental"|"development", default: boolean, description: string }>}
15
+ */
16
+ const FEATURE_REGISTRY = {
17
+ // ── Stable features ──────────────────────────────────────────────────────
18
+ smart_routing: {
19
+ stage: "stable",
20
+ default: true,
21
+ description: "Route tasks to optimal models",
22
+ },
23
+ task_decomposition: {
24
+ stage: "stable",
25
+ default: true,
26
+ description: "Split complex tasks into parallel subtasks",
27
+ },
28
+ loop_detection: {
29
+ stage: "stable",
30
+ default: true,
31
+ description: "Detect and break tool-call loops",
32
+ },
33
+ context_compaction: {
34
+ stage: "stable",
35
+ default: true,
36
+ description: "Auto-compact context when approaching token limit",
37
+ },
38
+
39
+ // ── Experimental features ────────────────────────────────────────────────
40
+ browser_integration: {
41
+ stage: "experimental",
42
+ default: false,
43
+ description: "Native browser control via local-browser-bridge",
44
+ },
45
+ auto_memory: {
46
+ stage: "experimental",
47
+ default: false,
48
+ description: "Auto-extract facts from conversations to memory",
49
+ },
50
+ tts: {
51
+ stage: "experimental",
52
+ default: false,
53
+ description: "Text-to-speech output",
54
+ },
55
+
56
+ // ── Under development ────────────────────────────────────────────────────
57
+ multi_agent: {
58
+ stage: "development",
59
+ default: false,
60
+ description: "Multi-agent orchestration patterns",
61
+ },
62
+ cloud_sync: {
63
+ stage: "development",
64
+ default: false,
65
+ description: "Sync sessions to cloud",
66
+ },
67
+ image_generation: {
68
+ stage: "development",
69
+ default: false,
70
+ description: "Generate images from prompts",
71
+ },
72
+ };
73
+
74
+ export class FeatureManager {
75
+ /**
76
+ * @param {string} [configPath] - Path to config.json (defaults to ~/.wispy/config.json)
77
+ */
78
+ constructor(configPath) {
79
+ this._configPath = configPath ?? CONFIG_PATH;
80
+ /** @type {Record<string, boolean>} — cached overrides from config */
81
+ this._overrides = null;
82
+ }
83
+
84
+ /**
85
+ * Load feature overrides from config (lazy, cached per call).
86
+ * @returns {Promise<Record<string, boolean>>}
87
+ */
88
+ async _loadOverrides() {
89
+ if (this._overrides !== null) return this._overrides;
90
+ try {
91
+ const raw = await readFile(this._configPath, "utf8");
92
+ const cfg = JSON.parse(raw);
93
+ this._overrides = cfg.features ?? {};
94
+ } catch {
95
+ this._overrides = {};
96
+ }
97
+ return this._overrides;
98
+ }
99
+
100
+ /** Invalidate the cache so next read picks up fresh config. */
101
+ _invalidate() {
102
+ this._overrides = null;
103
+ }
104
+
105
+ /**
106
+ * Check if a feature is enabled.
107
+ * Config override takes precedence over registry default.
108
+ * Profile-level features (passed in opts) take precedence over global config.
109
+ * @param {string} name
110
+ * @param {Record<string, boolean>} [profileFeatures] - Profile-level overrides
111
+ * @returns {Promise<boolean>}
112
+ */
113
+ async isEnabled(name, profileFeatures = {}) {
114
+ const reg = FEATURE_REGISTRY[name];
115
+ if (!reg) return false; // Unknown features are disabled
116
+
117
+ // Profile override first
118
+ if (name in profileFeatures) return Boolean(profileFeatures[name]);
119
+
120
+ // Config override second
121
+ const overrides = await this._loadOverrides();
122
+ if (name in overrides) return Boolean(overrides[name]);
123
+
124
+ // Registry default
125
+ return reg.default;
126
+ }
127
+
128
+ /**
129
+ * Synchronous isEnabled using pre-loaded overrides.
130
+ * Call _loadOverrides() first if needed.
131
+ * @param {string} name
132
+ * @param {Record<string, boolean>} [overrides]
133
+ * @param {Record<string, boolean>} [profileFeatures]
134
+ * @returns {boolean}
135
+ */
136
+ isEnabledSync(name, overrides = {}, profileFeatures = {}) {
137
+ const reg = FEATURE_REGISTRY[name];
138
+ if (!reg) return false;
139
+ if (name in profileFeatures) return Boolean(profileFeatures[name]);
140
+ if (name in overrides) return Boolean(overrides[name]);
141
+ return reg.default;
142
+ }
143
+
144
+ /**
145
+ * Enable a feature and persist to config.
146
+ * @param {string} name
147
+ */
148
+ async enable(name) {
149
+ if (!FEATURE_REGISTRY[name]) {
150
+ throw new Error(`Unknown feature: "${name}". Use "wispy features" to see available features.`);
151
+ }
152
+ const raw = await this._readConfig();
153
+ if (!raw.features) raw.features = {};
154
+ raw.features[name] = true;
155
+ await this._writeConfig(raw);
156
+ this._invalidate();
157
+ return { success: true, name, enabled: true };
158
+ }
159
+
160
+ /**
161
+ * Disable a feature and persist to config.
162
+ * @param {string} name
163
+ */
164
+ async disable(name) {
165
+ if (!FEATURE_REGISTRY[name]) {
166
+ throw new Error(`Unknown feature: "${name}". Use "wispy features" to see available features.`);
167
+ }
168
+ const raw = await this._readConfig();
169
+ if (!raw.features) raw.features = {};
170
+ raw.features[name] = false;
171
+ await this._writeConfig(raw);
172
+ this._invalidate();
173
+ return { success: true, name, enabled: false };
174
+ }
175
+
176
+ /**
177
+ * List all features with their current status.
178
+ * @returns {Promise<Array<{ name: string, stage: string, enabled: boolean, default: boolean, description: string }>>}
179
+ */
180
+ async list() {
181
+ const overrides = await this._loadOverrides();
182
+ return Object.entries(FEATURE_REGISTRY).map(([name, meta]) => ({
183
+ name,
184
+ stage: meta.stage,
185
+ enabled: name in overrides ? Boolean(overrides[name]) : meta.default,
186
+ default: meta.default,
187
+ description: meta.description,
188
+ }));
189
+ }
190
+
191
+ /**
192
+ * Get the stage of a feature.
193
+ * @param {string} name
194
+ * @returns {"stable"|"experimental"|"development"|null}
195
+ */
196
+ getStage(name) {
197
+ return FEATURE_REGISTRY[name]?.stage ?? null;
198
+ }
199
+
200
+ async _readConfig() {
201
+ await mkdir(WISPY_DIR, { recursive: true });
202
+ try {
203
+ return JSON.parse(await readFile(this._configPath, "utf8"));
204
+ } catch {
205
+ return {};
206
+ }
207
+ }
208
+
209
+ async _writeConfig(cfg) {
210
+ await mkdir(WISPY_DIR, { recursive: true });
211
+ await writeFile(this._configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
212
+ }
213
+ }
214
+
215
+ /** Singleton instance */
216
+ let _instance = null;
217
+
218
+ /** Get or create the global FeatureManager instance. */
219
+ export function getFeatureManager(configPath) {
220
+ if (!_instance) _instance = new FeatureManager(configPath);
221
+ return _instance;
222
+ }
223
+
224
+ /** Export the registry for introspection. */
225
+ export { FEATURE_REGISTRY };