leetcode-capture-helper 0.0.0-stage → 0.1.0

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.
Files changed (48) hide show
  1. package/cli.js +92 -0
  2. package/config.js +25 -0
  3. package/framing.js +57 -0
  4. package/host.js +346 -0
  5. package/install.js +290 -0
  6. package/package.json +31 -4
  7. package/runtime/bundle.json +42 -0
  8. package/runtime/companion/capture-log.js +83 -0
  9. package/runtime/companion/codex-backend.js +337 -0
  10. package/runtime/companion/companion.js +2193 -0
  11. package/runtime/companion/mouse-input.js +154 -0
  12. package/runtime/companion/package.json +4 -0
  13. package/runtime/companion/panel-bridge.js +273 -0
  14. package/runtime/companion/settings-rpc.js +228 -0
  15. package/runtime/companion/settings.js +464 -0
  16. package/runtime/companion/terminal-format.js +330 -0
  17. package/runtime/companion/vault-summary.js +169 -0
  18. package/runtime/companion/virtual-terminal.js +138 -0
  19. package/runtime/extension/shared/anthropic-backend.js +193 -0
  20. package/runtime/extension/shared/catalog.js +415 -0
  21. package/runtime/extension/shared/code-ledger.js +333 -0
  22. package/runtime/extension/shared/context-budget.js +259 -0
  23. package/runtime/extension/shared/context-summary.js +271 -0
  24. package/runtime/extension/shared/conversation.js +81 -0
  25. package/runtime/extension/shared/event-hub.js +126 -0
  26. package/runtime/extension/shared/extension-settings.js +445 -0
  27. package/runtime/extension/shared/helper-launcher.js +248 -0
  28. package/runtime/extension/shared/local-backend.js +706 -0
  29. package/runtime/extension/shared/local-server.js +149 -0
  30. package/runtime/extension/shared/package.json +4 -0
  31. package/runtime/extension/shared/practice.js +228 -0
  32. package/runtime/extension/shared/problem-index.js +4128 -0
  33. package/runtime/extension/shared/profiles.js +106 -0
  34. package/runtime/extension/shared/providers.js +58 -0
  35. package/runtime/extension/shared/reply-guard.js +864 -0
  36. package/runtime/extension/shared/roadmap.js +1344 -0
  37. package/runtime/extension/shared/sanitize.js +68 -0
  38. package/runtime/extension/shared/solved.js +277 -0
  39. package/runtime/extension/shared/tutor-engine.js +369 -0
  40. package/runtime/extension/shared/tutor-prompt.js +108 -0
  41. package/runtime/extension/shared/tutor-service.js +520 -0
  42. package/runtime/relay-server/package.json +4 -0
  43. package/runtime/relay-server/panel-broker.js +797 -0
  44. package/runtime/relay-server/server.js +241 -0
  45. package/runtime/vault-tool/log-session.js +230 -0
  46. package/runtime/vault-tool/package.json +4 -0
  47. package/runtime/vault-tool/vault-notes.js +206 -0
  48. package/README.md +0 -3
@@ -0,0 +1,464 @@
1
+ // companion/settings.js - the companion's persisted settings.
2
+ //
3
+ // One place decides which LLM the tutor talks to and how, so the browser
4
+ // extension's options page, the terminal, and the existing environment
5
+ // variables all agree. Pure and dependency-free so it can be unit-tested
6
+ // without booting companion.js.
7
+ //
8
+ // Precedence, per field: environment variable > saved settings file > default.
9
+ // An environment variable that is set locks its field in the options page
10
+ // (the page is told which variable), so a launcher script that exports
11
+ // COMPANION_MODEL keeps working exactly as it always has.
12
+ //
13
+ // Secrets: API keys live only in the settings file (mode 0600 inside a 0700
14
+ // directory) and in this process's memory. publicView() is the only thing the
15
+ // options page ever receives and it carries `hasKey`, never the key itself.
16
+
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ export const SETTINGS_VERSION = 1;
22
+ export const DEFAULT_PROVIDER = 'claude';
23
+
24
+ // The provider catalog (labels, fields, requirements, which runtimes can run
25
+ // each one) is shared with the extension: extension/shared/providers.js.
26
+ export { PROVIDERS, OLLAMA_BASE_URL, OPENAI_BASE_URL } from '../extension/shared/providers.js';
27
+ import { PROVIDERS, OLLAMA_BASE_URL, OPENAI_BASE_URL } from '../extension/shared/providers.js';
28
+ import {
29
+ ProfileError,
30
+ cleanActiveProfile,
31
+ cleanProfiles,
32
+ findProfile,
33
+ migrateProfiles,
34
+ } from '../extension/shared/profiles.js';
35
+
36
+ const DEFAULT_FIELDS = {
37
+ local: { baseUrl: OLLAMA_BASE_URL, model: null, contextTokens: 8192, apiKey: null },
38
+ claude: { model: null },
39
+ anthropic: { apiKey: null, model: null },
40
+ openai: { apiKey: null, model: null, baseUrl: OPENAI_BASE_URL },
41
+ chatgpt: { model: null },
42
+ };
43
+
44
+ export const DEFAULT_OPTIONS = { autoClearContext: true, vaultAutoSummary: false, localMaxHistoryTurns: 6 };
45
+
46
+ // Context window assumed for hosted OpenAI-compatible APIs (the pre-flight
47
+ // size check in LocalBackend needs a number; 128k is the floor for current
48
+ // hosted models).
49
+ export const OPENAI_CONTEXT_TOKENS = 128000;
50
+
51
+ export class SettingsError extends Error {
52
+ constructor(message, field) {
53
+ super(message);
54
+ this.name = 'SettingsError';
55
+ this.field = field || null;
56
+ }
57
+ }
58
+
59
+ export function settingsPath(env = process.env) {
60
+ if (env.LEETCODE_CAPTURE_SETTINGS) return env.LEETCODE_CAPTURE_SETTINGS;
61
+ const base =
62
+ env.XDG_CONFIG_HOME ||
63
+ (process.platform === 'win32' && env.APPDATA ? env.APPDATA : path.join(os.homedir(), '.config'));
64
+ return path.join(base, 'leetcode-capture', 'settings.json');
65
+ }
66
+
67
+ export function emptySettings() {
68
+ const providers = {};
69
+ for (const id of Object.keys(PROVIDERS)) providers[id] = { ...DEFAULT_FIELDS[id] };
70
+ return { version: SETTINGS_VERSION, provider: null, providers, options: { ...DEFAULT_OPTIONS }, profiles: [], activeProfile: null };
71
+ }
72
+
73
+ // The saved models the Tutor panel's switcher may move the helper between
74
+ // (extension/shared/profiles.js). The options page keeps this list equal to
75
+ // its own list of the profiles that run through the helper. `provider` and
76
+ // that provider's model always match the active profile.
77
+ function syncActive(settings) {
78
+ const profile = findProfile(settings.profiles, settings.activeProfile);
79
+ if (!profile) return;
80
+ settings.provider = profile.provider;
81
+ settings.providers[profile.provider].model = profile.model;
82
+ }
83
+
84
+ // ---- validation ------------------------------------------------------------
85
+
86
+ function cleanString(value, max, field, { allowEmpty = true } = {}) {
87
+ if (value === null || value === undefined) return null;
88
+ if (typeof value !== 'string') throw new SettingsError(`${field} must be text`, field);
89
+ const trimmed = value.trim();
90
+ if (!trimmed) {
91
+ if (allowEmpty) return null;
92
+ throw new SettingsError(`${field} can't be empty`, field);
93
+ }
94
+ if (trimmed.length > max) throw new SettingsError(`${field} is too long`, field);
95
+ // eslint-disable-next-line no-control-regex
96
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) throw new SettingsError(`${field} contains control characters`, field);
97
+ return trimmed;
98
+ }
99
+
100
+ function cleanUrl(value, field) {
101
+ const text = cleanString(value, 500, field);
102
+ if (text === null) return null;
103
+ let url;
104
+ try {
105
+ url = new URL(text);
106
+ } catch {
107
+ throw new SettingsError(`${field} must be a full URL like http://localhost:11434/v1`, field);
108
+ }
109
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
110
+ throw new SettingsError(`${field} must start with http:// or https://`, field);
111
+ }
112
+ return text.replace(/\/+$/, '');
113
+ }
114
+
115
+ function cleanApiKey(value) {
116
+ const text = cleanString(value, 400, 'apiKey');
117
+ if (text !== null && /\s/.test(text)) throw new SettingsError('apiKey must not contain spaces', 'apiKey');
118
+ return text;
119
+ }
120
+
121
+ function cleanInt(value, min, max, field) {
122
+ if (value === null || value === undefined || value === '') return null;
123
+ const n = Number(value);
124
+ if (!Number.isInteger(n) || n < min || n > max) {
125
+ throw new SettingsError(`${field} must be a whole number between ${min} and ${max}`, field);
126
+ }
127
+ return n;
128
+ }
129
+
130
+ const FIELD_CLEANERS = {
131
+ model: (v) => cleanString(v, 200, 'model'),
132
+ baseUrl: (v) => cleanUrl(v, 'baseUrl'),
133
+ contextTokens: (v) => cleanInt(v, 1024, 2_000_000, 'contextTokens'),
134
+ apiKey: cleanApiKey,
135
+ };
136
+
137
+ function cleanBool(value, field) {
138
+ if (typeof value !== 'boolean') throw new SettingsError(`${field} must be on or off`, field);
139
+ return value;
140
+ }
141
+
142
+ // ---- file store --------------------------------------------------------------
143
+
144
+ // Reads the settings file. A missing file is the normal first-run state; a
145
+ // corrupt one is reported (and treated as empty) rather than crashing.
146
+ export function loadSettings(file = settingsPath()) {
147
+ let raw;
148
+ try {
149
+ raw = fs.readFileSync(file, 'utf8');
150
+ } catch (err) {
151
+ if (err.code === 'ENOENT') return { settings: emptySettings(), warning: null };
152
+ return { settings: emptySettings(), warning: `could not read ${file}: ${err.message}` };
153
+ }
154
+ let parsed;
155
+ try {
156
+ parsed = JSON.parse(raw);
157
+ } catch (err) {
158
+ return { settings: emptySettings(), warning: `${file} is not valid JSON (${err.message}); ignoring it` };
159
+ }
160
+ return { settings: sanitizeStored(parsed), warning: null };
161
+ }
162
+
163
+ // Keeps only known, valid values; anything else falls back to the default.
164
+ export function sanitizeStored(input) {
165
+ const out = emptySettings();
166
+ if (!input || typeof input !== 'object') return out;
167
+ if (typeof input.provider === 'string' && PROVIDERS[input.provider]) out.provider = input.provider;
168
+ for (const id of Object.keys(PROVIDERS)) {
169
+ const src = input.providers && input.providers[id];
170
+ if (!src || typeof src !== 'object') continue;
171
+ for (const field of Object.keys(DEFAULT_FIELDS[id])) {
172
+ if (!(field in src)) continue;
173
+ try {
174
+ const value = FIELD_CLEANERS[field](src[field]);
175
+ out.providers[id][field] = value === null ? DEFAULT_FIELDS[id][field] : value;
176
+ } catch {
177
+ // invalid stored value: keep the default
178
+ }
179
+ }
180
+ }
181
+ const opts = input.options;
182
+ if (opts && typeof opts === 'object') {
183
+ if (typeof opts.autoClearContext === 'boolean') out.options.autoClearContext = opts.autoClearContext;
184
+ if (typeof opts.vaultAutoSummary === 'boolean') out.options.vaultAutoSummary = opts.vaultAutoSummary;
185
+ try {
186
+ const turns = cleanInt(opts.localMaxHistoryTurns, 0, 100, 'localMaxHistoryTurns');
187
+ if (turns !== null) out.options.localMaxHistoryTurns = turns;
188
+ } catch {
189
+ // keep default
190
+ }
191
+ }
192
+ const migrated = migrateProfiles({
193
+ profiles: cleanProfiles(input.profiles, PROVIDERS),
194
+ activeProfile: input.activeProfile,
195
+ provider: out.provider,
196
+ model: out.provider ? out.providers[out.provider].model : null,
197
+ });
198
+ out.profiles = migrated.profiles;
199
+ out.activeProfile = migrated.activeProfile;
200
+ syncActive(out);
201
+ return out;
202
+ }
203
+
204
+ // Atomic write: a crash mid-write must never leave a half-written file that
205
+ // then contains (or drops) a key. Directory 0700, file 0600.
206
+ export function saveSettings(settings, file = settingsPath()) {
207
+ const dir = path.dirname(file);
208
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
209
+ const tmp = `${file}.${process.pid}.tmp`;
210
+ fs.writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
211
+ fs.renameSync(tmp, file);
212
+ try {
213
+ fs.chmodSync(file, 0o600);
214
+ } catch {
215
+ // best effort on filesystems without modes
216
+ }
217
+ }
218
+
219
+ // ---- applying a change from the options page ------------------------------------
220
+
221
+ // patch = { provider?, providers?: { [id]: { model?, baseUrl?, contextTokens?, apiKey? } }, options?,
222
+ // profiles?, activeProfile? }
223
+ // apiKey: a string sets it, null removes it, undefined (absent) keeps what is saved.
224
+ // `profiles` replaces the whole list; `activeProfile` (an id in it, or null)
225
+ // makes that profile's provider and model the ones in use.
226
+ // Returns a new stored object; throws SettingsError and changes nothing on a bad value.
227
+ export function applyPatch(stored, patch) {
228
+ const next = JSON.parse(JSON.stringify(stored));
229
+ if (!patch || typeof patch !== 'object') throw new SettingsError('nothing to save');
230
+ if (patch.provider !== undefined) {
231
+ if (typeof patch.provider !== 'string' || !PROVIDERS[patch.provider]) {
232
+ throw new SettingsError('unknown provider', 'provider');
233
+ }
234
+ next.provider = patch.provider;
235
+ }
236
+ if (patch.providers !== undefined) {
237
+ if (!patch.providers || typeof patch.providers !== 'object') throw new SettingsError('providers must be an object');
238
+ for (const [id, fields] of Object.entries(patch.providers)) {
239
+ if (!PROVIDERS[id]) throw new SettingsError(`unknown provider ${id}`, 'provider');
240
+ if (!fields || typeof fields !== 'object') throw new SettingsError(`${id} settings must be an object`);
241
+ for (const [field, value] of Object.entries(fields)) {
242
+ if (!(field in DEFAULT_FIELDS[id])) continue; // not a field of this provider
243
+ if (value === undefined) continue;
244
+ const cleaned = FIELD_CLEANERS[field](value);
245
+ next.providers[id][field] = cleaned === null ? DEFAULT_FIELDS[id][field] : cleaned;
246
+ }
247
+ }
248
+ }
249
+ if (patch.options !== undefined) {
250
+ if (!patch.options || typeof patch.options !== 'object') throw new SettingsError('options must be an object');
251
+ const o = patch.options;
252
+ if (o.autoClearContext !== undefined) next.options.autoClearContext = cleanBool(o.autoClearContext, 'autoClearContext');
253
+ if (o.vaultAutoSummary !== undefined) next.options.vaultAutoSummary = cleanBool(o.vaultAutoSummary, 'vaultAutoSummary');
254
+ if (o.localMaxHistoryTurns !== undefined) {
255
+ const turns = cleanInt(o.localMaxHistoryTurns, 0, 100, 'localMaxHistoryTurns');
256
+ next.options.localMaxHistoryTurns = turns === null ? DEFAULT_OPTIONS.localMaxHistoryTurns : turns;
257
+ }
258
+ }
259
+ if (!Array.isArray(next.profiles)) next.profiles = [];
260
+ if (patch.profiles !== undefined) {
261
+ try {
262
+ next.profiles = cleanProfiles(patch.profiles, PROVIDERS, { strict: true });
263
+ } catch (err) {
264
+ throw new SettingsError(err instanceof ProfileError ? err.message : 'profiles are not valid', 'profiles');
265
+ }
266
+ next.activeProfile = cleanActiveProfile(next.profiles, next.activeProfile);
267
+ }
268
+ if (patch.activeProfile !== undefined) {
269
+ if (patch.activeProfile === null) next.activeProfile = null;
270
+ else if (!findProfile(next.profiles, patch.activeProfile)) throw new SettingsError('That model is not in the saved list.', 'activeProfile');
271
+ else next.activeProfile = patch.activeProfile;
272
+ } else if (patch.profiles === undefined && (patch.provider !== undefined || patch.providers !== undefined)) {
273
+ // A patch from before profiles: the active profile follows the provider
274
+ // and model it saves, or there is none.
275
+ followLegacyPatch(next);
276
+ }
277
+ syncActive(next);
278
+ return next;
279
+ }
280
+
281
+ // A patch from before profiles (a provider and its fields, no profile list):
282
+ // the active profile follows the provider and model it saves, as loading
283
+ // such a file would have migrated it.
284
+ function followLegacyPatch(next) {
285
+ if (!next.provider) {
286
+ next.activeProfile = null;
287
+ return;
288
+ }
289
+ const model = next.providers[next.provider].model;
290
+ const active = findProfile(next.profiles, next.activeProfile);
291
+ if (active && active.provider === next.provider) {
292
+ active.model = model;
293
+ return;
294
+ }
295
+ const same = next.profiles.find((p) => p.provider === next.provider && p.model === model);
296
+ if (same) {
297
+ next.activeProfile = same.id;
298
+ return;
299
+ }
300
+ let id = next.profiles.length ? next.provider : 'default';
301
+ for (let n = 2; findProfile(next.profiles, id); n += 1) id = `${next.provider}-${n}`;
302
+ next.profiles.push({ id, provider: next.provider, model, label: null });
303
+ next.activeProfile = id;
304
+ }
305
+
306
+ // ---- effective settings (env > file > default) ---------------------------------
307
+
308
+ const TRUE_RE = /^(1|true|yes)$/i;
309
+ const FALSE_RE = /^(0|false|no)$/i;
310
+
311
+ // What the companion actually runs with, plus which environment variable (if
312
+ // any) pinned each field. `apiKey` is in the result because the backend needs
313
+ // it, so this object must never be sent to the options page - use publicView.
314
+ export function resolveEffective(stored, env = process.env) {
315
+ const locks = {};
316
+ let provider = stored.provider || DEFAULT_PROVIDER;
317
+ if (env.COMPANION_BACKEND) {
318
+ if (!PROVIDERS[env.COMPANION_BACKEND]) {
319
+ throw new SettingsError(
320
+ `unknown COMPANION_BACKEND "${env.COMPANION_BACKEND}" (expected one of ${Object.keys(PROVIDERS).join(', ')})`,
321
+ 'provider'
322
+ );
323
+ }
324
+ provider = env.COMPANION_BACKEND;
325
+ locks.provider = 'COMPANION_BACKEND';
326
+ }
327
+ const fields = { ...stored.providers[provider] };
328
+ const has = (field) => field in DEFAULT_FIELDS[provider];
329
+
330
+ if (env.COMPANION_MODEL && has('model')) {
331
+ fields.model = env.COMPANION_MODEL;
332
+ locks.model = 'COMPANION_MODEL';
333
+ }
334
+ if (env.COMPANION_BASE_URL && has('baseUrl')) {
335
+ fields.baseUrl = env.COMPANION_BASE_URL;
336
+ locks.baseUrl = 'COMPANION_BASE_URL';
337
+ }
338
+ if (env.COMPANION_LOCAL_NUM_CTX && has('contextTokens')) {
339
+ fields.contextTokens = Number(env.COMPANION_LOCAL_NUM_CTX);
340
+ locks.contextTokens = 'COMPANION_LOCAL_NUM_CTX';
341
+ }
342
+ let keySource = fields.apiKey ? 'saved' : null;
343
+ if (has('apiKey') && !fields.apiKey) {
344
+ const fallback = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY' }[provider];
345
+ if (env.COMPANION_API_KEY) {
346
+ fields.apiKey = env.COMPANION_API_KEY;
347
+ keySource = 'COMPANION_API_KEY';
348
+ } else if (fallback && env[fallback]) {
349
+ fields.apiKey = env[fallback];
350
+ keySource = fallback;
351
+ }
352
+ } else if (has('apiKey') && env.COMPANION_API_KEY) {
353
+ fields.apiKey = env.COMPANION_API_KEY;
354
+ keySource = 'COMPANION_API_KEY';
355
+ }
356
+
357
+ const options = { ...stored.options };
358
+ if (env.VAULT_AUTO_SUMMARY !== undefined && env.VAULT_AUTO_SUMMARY !== '') {
359
+ options.vaultAutoSummary = TRUE_RE.test(env.VAULT_AUTO_SUMMARY);
360
+ locks.vaultAutoSummary = 'VAULT_AUTO_SUMMARY';
361
+ }
362
+ if (env.COMPANION_AUTO_CLEAR_CONTEXT !== undefined && env.COMPANION_AUTO_CLEAR_CONTEXT !== '') {
363
+ options.autoClearContext = !FALSE_RE.test(env.COMPANION_AUTO_CLEAR_CONTEXT);
364
+ locks.autoClearContext = 'COMPANION_AUTO_CLEAR_CONTEXT';
365
+ }
366
+ if (env.COMPANION_LOCAL_MAX_HISTORY_TURNS) {
367
+ options.localMaxHistoryTurns = Number(env.COMPANION_LOCAL_MAX_HISTORY_TURNS);
368
+ locks.localMaxHistoryTurns = 'COMPANION_LOCAL_MAX_HISTORY_TURNS';
369
+ }
370
+
371
+ let contextTokens = fields.contextTokens || null;
372
+ if (provider === 'openai') contextTokens = OPENAI_CONTEXT_TOKENS;
373
+ // The saved profile this runs as, when an env var does not override it.
374
+ const active = findProfile(stored.profiles, stored.activeProfile);
375
+ const profile = active && active.provider === provider && (active.model || null) === (fields.model || null) ? active.id : null;
376
+ return {
377
+ provider,
378
+ profile,
379
+ model: fields.model || null,
380
+ baseUrl: fields.baseUrl || null,
381
+ apiKey: fields.apiKey || null,
382
+ keySource,
383
+ contextTokens,
384
+ options,
385
+ locks,
386
+ };
387
+ }
388
+
389
+ // Returns the human-readable problem with an effective config, or null if a
390
+ // backend can be built from it.
391
+ export function missingRequirement(effective) {
392
+ const spec = PROVIDERS[effective.provider];
393
+ for (const field of spec.requires) {
394
+ if (!effective[field]) {
395
+ const what = { model: 'a model name', apiKey: 'an API key' }[field] || field;
396
+ return `${spec.label} needs ${what}`;
397
+ }
398
+ }
399
+ return null;
400
+ }
401
+
402
+ // ---- backend environments ------------------------------------------------------
403
+
404
+ // Environment variables that make Claude Code bill an API key (or another
405
+ // token) instead of the sign-in the subscription provider was picked for.
406
+ const ANTHROPIC_KEY_ENV = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
407
+
408
+ // The environment the Claude backend's Claude Code process runs with (the
409
+ // Agent SDK's `env` option). Without an apiKey (the "Claude subscription"
410
+ // provider) every key variable is removed, so a stray ANTHROPIC_API_KEY can
411
+ // never silently bill instead of the subscription; with one (the "Anthropic
412
+ // API key" provider) exactly that key is set.
413
+ export function claudeBackendEnv(apiKey, env = process.env) {
414
+ const out = { ...env };
415
+ for (const name of ANTHROPIC_KEY_ENV) delete out[name];
416
+ if (apiKey) out.ANTHROPIC_API_KEY = apiKey;
417
+ return out;
418
+ }
419
+
420
+ // ---- what other parties may see -----------------------------------------------
421
+
422
+ // Non-secret summary of what is running; published to the in-page panel,
423
+ // whose model switcher follows `profile` (the saved profile in use, or null).
424
+ export function statusSummary(effective) {
425
+ return {
426
+ provider: effective.provider,
427
+ label: PROVIDERS[effective.provider].label,
428
+ model: effective.model || null,
429
+ profile: effective.profile || null,
430
+ };
431
+ }
432
+
433
+ // The options page's view. Keys are replaced by { hasKey, keySource }.
434
+ export function publicView(stored, env = process.env, file = settingsPath(env)) {
435
+ const effective = resolveEffective(stored, env);
436
+ const providers = {};
437
+ for (const id of Object.keys(PROVIDERS)) {
438
+ const saved = stored.providers[id];
439
+ const view = {};
440
+ for (const field of Object.keys(DEFAULT_FIELDS[id])) {
441
+ if (field !== 'apiKey') view[field] = saved[field];
442
+ }
443
+ if ('apiKey' in DEFAULT_FIELDS[id]) {
444
+ const eff = id === effective.provider ? effective : resolveEffective({ ...stored, provider: id }, {});
445
+ view.hasKey = Boolean(saved.apiKey) || (id === effective.provider && Boolean(effective.apiKey));
446
+ view.keySource = saved.apiKey ? 'saved' : id === effective.provider ? effective.keySource : eff.keySource;
447
+ }
448
+ providers[id] = view;
449
+ }
450
+ return {
451
+ provider: effective.provider,
452
+ savedProvider: stored.provider,
453
+ providers,
454
+ options: effective.options,
455
+ locks: effective.locks,
456
+ profiles: (stored.profiles || []).map((p) => ({ ...p })),
457
+ activeProfile: stored.activeProfile || null,
458
+ catalog: Object.fromEntries(
459
+ Object.entries(PROVIDERS).map(([id, p]) => [id, { label: p.label, hint: p.hint, fields: p.fields, requires: p.requires }])
460
+ ),
461
+ defaults: { ...DEFAULT_FIELDS, options: DEFAULT_OPTIONS },
462
+ settingsPath: file,
463
+ };
464
+ }