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 +293 -0
- package/core/config.mjs +29 -11
- package/core/features.mjs +225 -0
- package/core/loop-detector.mjs +183 -0
- package/core/memory.mjs +255 -0
- package/core/secrets.mjs +251 -0
- package/core/session.mjs +4 -1
- package/core/tts.mjs +194 -0
- package/lib/jsonl-emitter.mjs +101 -0
- package/package.json +1 -1
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
[
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 };
|