@ruvnet/open-claude-code 2.0.1 → 2.1.1

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/package.json +29 -4
  4. package/src/auth/oauth.mjs +46 -6
  5. package/src/config/cli-args.mjs +20 -0
  6. package/src/config/env.mjs +2 -2
  7. package/src/config/settings.mjs +30 -130
  8. package/src/core/agent-loop.mjs +92 -28
  9. package/src/core/providers.mjs +19 -1
  10. package/src/core/session.mjs +34 -149
  11. package/src/core/system-prompt.mjs +43 -47
  12. package/src/hooks/engine.mjs +10 -2
  13. package/src/index.mjs +60 -32
  14. package/src/optimize/cascade.mjs +128 -0
  15. package/src/optimize/commands.mjs +139 -0
  16. package/src/optimize/router.mjs +212 -0
  17. package/src/optimize/store.mjs +170 -0
  18. package/src/permissions/checker.mjs +8 -79
  19. package/src/permissions/injection-check.mjs +3 -2
  20. package/src/permissions/path-check.mjs +8 -0
  21. package/src/permissions/sandbox.mjs +8 -2
  22. package/src/permissions/ssrf-check.mjs +102 -0
  23. package/src/plugins/loader.mjs +14 -6
  24. package/src/skills/loader.mjs +16 -4
  25. package/src/tools/bash.mjs +3 -40
  26. package/src/tools/enter-worktree.mjs +5 -3
  27. package/src/tools/exit-worktree.mjs +4 -3
  28. package/src/tools/grep.mjs +26 -11
  29. package/src/tools/lsp.mjs +23 -9
  30. package/src/tools/read.mjs +20 -9
  31. package/src/tools/send-message.mjs +5 -1
  32. package/src/tools/web-fetch.mjs +15 -1
  33. package/src/tools/web-search.mjs +13 -1
  34. package/src/ui/app.mjs +286 -0
  35. package/src/ui/commands.mjs +121 -71
  36. package/src/ui/components.mjs +219 -0
  37. package/src/ui/ink-app.mjs +13 -52
  38. package/src/ui/markdown.mjs +278 -0
  39. package/src/ui/repl.mjs +10 -25
  40. package/test/test.mjs +0 -2657
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Supports all 76 properties from Claude Code's settings schema.
5
5
  * Five-layer hierarchy: user > project > local > managed > feature flags.
6
- * Deep merge with source tracking and write-back.
7
6
  */
8
7
  import fs from 'fs';
9
8
  import path from 'path';
@@ -59,103 +58,34 @@ export const SETTINGS_SCHEMA = {
59
58
  agentId: null,
60
59
  cronEnabled: true,
61
60
  featureFlags: {},
61
+ // Metaharness self-optimization (opt-in; default OFF — behavior unchanged).
62
+ selfOptimize: false,
63
+ selfOptimizeQualityBar: 0.7,
64
+ selfOptimizeLadder: null,
62
65
  };
63
66
 
64
- /** Sources in load order (first = lowest priority, last = highest) */
65
- const SCOPE_NAMES = ['managed', 'user', 'project', 'local'];
66
-
67
- /**
68
- * Get file paths for each settings scope.
69
- * @param {string} [cwd]
70
- * @returns {Record<string, string>}
71
- */
72
- export function getSettingsPaths(cwd = process.cwd()) {
73
- return {
74
- user: path.join(os.homedir(), '.claude', 'settings.json'),
75
- project: path.join(cwd, '.claude', 'settings.json'),
76
- local: path.join(cwd, '.claude', 'settings.local.json'),
77
- managed: path.join(os.homedir(), '.claude', 'settings.managed.json'),
78
- };
79
- }
80
-
81
- /**
82
- * Load settings with 4-source deep merge and source tracking.
83
- * Order: defaults -> managed -> user -> project -> local -> env overrides
84
- * @param {string} [cwd]
85
- * @returns {Promise<object>} merged settings with _sources metadata
86
- */
87
- export async function loadSettings(cwd) {
88
- const paths = getSettingsPaths(cwd);
67
+ export async function loadSettings() {
89
68
  const chain = [
90
- { scope: 'managed', file: paths.managed },
91
- { scope: 'user', file: paths.user },
92
- { scope: 'project', file: paths.project },
93
- { scope: 'local', file: paths.local },
69
+ path.join(os.homedir(), '.claude', 'settings.json'),
70
+ path.join(process.cwd(), '.claude', 'settings.json'),
71
+ path.join(process.cwd(), '.claude', 'settings.local.json'),
94
72
  ];
95
73
 
96
74
  let merged = deepClone(SETTINGS_SCHEMA);
97
- const sources = {};
98
75
 
99
- // Track defaults
100
- trackSources(sources, SETTINGS_SCHEMA, 'default', '');
101
-
102
- for (const { scope, file } of chain) {
76
+ for (const file of chain) {
103
77
  try {
104
78
  const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
105
79
  merged = deepMerge(merged, data);
106
- trackSources(sources, data, scope, '');
107
80
  } catch {
108
- // File not found or invalid — skip
81
+ // File not found or invalid
109
82
  }
110
83
  }
111
84
 
112
- applyEnvOverrides(merged, sources);
113
-
114
- // Attach metadata (non-enumerable so it does not interfere with tests)
115
- Object.defineProperty(merged, '_sources', { value: sources, enumerable: false, writable: true });
116
- Object.defineProperty(merged, '_paths', { value: paths, enumerable: false, writable: true });
117
-
85
+ applyEnvOverrides(merged);
118
86
  return merged;
119
87
  }
120
88
 
121
- /**
122
- * Save a single setting back to the correct scope file.
123
- * @param {'user'|'project'|'local'} scope
124
- * @param {string} key — dot-separated path (e.g. "permissions.defaultMode")
125
- * @param {*} value
126
- * @param {string} [cwd]
127
- */
128
- export function saveSettings(scope, key, value, cwd) {
129
- const paths = getSettingsPaths(cwd);
130
- const file = paths[scope];
131
- if (!file) throw new Error(`Unknown scope: ${scope}`);
132
-
133
- let existing = {};
134
- try {
135
- existing = JSON.parse(fs.readFileSync(file, 'utf-8'));
136
- } catch {
137
- // New file
138
- }
139
-
140
- setNestedValue(existing, key, value);
141
-
142
- const dir = path.dirname(file);
143
- fs.mkdirSync(dir, { recursive: true });
144
- fs.writeFileSync(file, JSON.stringify(existing, null, 2) + '\n');
145
- }
146
-
147
- /**
148
- * Get the source scope for a specific setting key.
149
- * @param {object} settings — settings object returned by loadSettings
150
- * @param {string} key — dot-separated path
151
- * @returns {string} scope name ('default', 'user', 'project', 'local', 'managed', 'env')
152
- */
153
- export function getSettingSource(settings, key) {
154
- return settings._sources?.[key] || 'default';
155
- }
156
-
157
- // ---------- Helpers ----------
158
-
159
89
  function deepClone(obj) {
160
90
  return JSON.parse(JSON.stringify(obj));
161
91
  }
@@ -163,8 +93,7 @@ function deepClone(obj) {
163
93
  function deepMerge(target, source) {
164
94
  const result = { ...target };
165
95
  for (const [key, value] of Object.entries(source)) {
166
- if (value && typeof value === 'object' && !Array.isArray(value)
167
- && typeof result[key] === 'object' && !Array.isArray(result[key])) {
96
+ if (value && typeof value === 'object' && !Array.isArray(value) && typeof result[key] === 'object' && !Array.isArray(result[key])) {
168
97
  result[key] = deepMerge(result[key] || {}, value);
169
98
  } else {
170
99
  result[key] = value;
@@ -173,53 +102,24 @@ function deepMerge(target, source) {
173
102
  return result;
174
103
  }
175
104
 
176
- function trackSources(sources, obj, scope, prefix) {
177
- for (const [key, value] of Object.entries(obj)) {
178
- const fullKey = prefix ? `${prefix}.${key}` : key;
179
- sources[fullKey] = scope;
180
- if (value && typeof value === 'object' && !Array.isArray(value)) {
181
- trackSources(sources, value, scope, fullKey);
182
- }
183
- }
184
- }
185
-
186
- function setNestedValue(obj, key, value) {
187
- const parts = key.split('.');
188
- let cur = obj;
189
- for (let i = 0; i < parts.length - 1; i++) {
190
- if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') {
191
- cur[parts[i]] = {};
192
- }
193
- cur = cur[parts[i]];
105
+ function applyEnvOverrides(settings) {
106
+ if (process.env.ANTHROPIC_MODEL) settings.model = process.env.ANTHROPIC_MODEL;
107
+ if (process.env.CLAUDE_CODE_SUBAGENT_MODEL) settings.subagentModel = process.env.CLAUDE_CODE_SUBAGENT_MODEL;
108
+ if (process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
109
+ const n = parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10);
110
+ if (!isNaN(n)) { settings.maxOutputTokens = n; settings.maxTokens = n; }
194
111
  }
195
- cur[parts[parts.length - 1]] = value;
196
- }
197
-
198
- function applyEnvOverrides(settings, sources) {
199
- const envMap = [
200
- ['ANTHROPIC_MODEL', (v) => { settings.model = v; }, 'model'],
201
- ['CLAUDE_CODE_SUBAGENT_MODEL', (v) => { settings.subagentModel = v; }, 'subagentModel'],
202
- ['CLAUDE_CODE_MAX_OUTPUT_TOKENS', (v) => {
203
- const n = parseInt(v, 10);
204
- if (!isNaN(n)) { settings.maxOutputTokens = n; settings.maxTokens = n; }
205
- }, 'maxOutputTokens'],
206
- ['CLAUDE_CODE_MAX_CONTEXT_TOKENS', (v) => {
207
- const n = parseInt(v, 10);
208
- if (!isNaN(n)) settings.maxContextTokens = n;
209
- }, 'maxContextTokens'],
210
- ['CLAUDE_CODE_BRIEF', (v) => { if (v === '1') settings.briefMode = true; }, 'briefMode'],
211
- ['CLAUDE_CODE_DEBUG', (v) => { if (v === '1') settings.debugMode = true; }, 'debugMode'],
212
- ['CLAUDE_CODE_PERMISSION_MODE', (v) => { settings.permissions.defaultMode = v; }, 'permissions.defaultMode'],
213
- ['CLAUDE_CODE_STREAMING', (v) => { if (v === '0') settings.stream = false; }, 'stream'],
214
- ['CLAUDE_CODE_THINKING', (v) => { if (v === '1') settings.alwaysThinkingEnabled = true; }, 'alwaysThinkingEnabled'],
215
- ['CLAUDE_CODE_DISABLE_CRON', (v) => { if (v === '1') settings.cronEnabled = false; }, 'cronEnabled'],
216
- ['CLAUDE_CODE_ENABLE_TASKS', (v) => { if (v === '1') settings.enableTeams = true; }, 'enableTeams'],
217
- ];
218
-
219
- for (const [envKey, apply, settingsKey] of envMap) {
220
- if (process.env[envKey]) {
221
- apply(process.env[envKey]);
222
- if (sources) sources[settingsKey] = 'env';
223
- }
112
+ if (process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS) {
113
+ const n = parseInt(process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, 10);
114
+ if (!isNaN(n)) settings.maxContextTokens = n;
224
115
  }
116
+ if (process.env.CLAUDE_CODE_BRIEF === '1') settings.briefMode = true;
117
+ if (process.env.CLAUDE_CODE_DEBUG === '1') settings.debugMode = true;
118
+ if (process.env.CLAUDE_CODE_PERMISSION_MODE) settings.permissions.defaultMode = process.env.CLAUDE_CODE_PERMISSION_MODE;
119
+ if (process.env.CLAUDE_CODE_STREAMING === '0') settings.stream = false;
120
+ if (process.env.CLAUDE_CODE_THINKING === '1') settings.alwaysThinkingEnabled = true;
121
+ if (process.env.CLAUDE_CODE_DISABLE_CRON === '1') settings.cronEnabled = false;
122
+ if (process.env.CLAUDE_CODE_ENABLE_TASKS === '1') settings.enableTeams = true;
123
+ if (process.env.CLAUDE_CODE_SELF_OPTIMIZE === '1') settings.selfOptimize = true;
124
+ if (process.env.CLAUDE_CODE_SELF_OPTIMIZE === '0') settings.selfOptimize = false;
225
125
  }
@@ -5,9 +5,12 @@
5
5
  import { streamResponse, accumulateStream } from './streaming.mjs';
6
6
  import { ContextManager } from './context-manager.mjs';
7
7
  import { buildSystemPrompt } from './system-prompt.mjs';
8
- import fs from 'fs';
9
- import path from 'path';
10
- export function createAgentLoop({ model, tools, permissions, settings, hooks }) {
8
+ import { readApiKey } from './providers.mjs';
9
+
10
+ /** Maximum number of consecutive tool-use continuation turns before aborting. */
11
+ const MAX_TOOL_RECURSION_DEPTH = 50;
12
+
13
+ export function createAgentLoop({ model, tools, permissions, settings, hooks, cascade = null }) {
11
14
  const contextManager = new ContextManager(settings.maxContextTokens || 180000);
12
15
 
13
16
  // Build system prompt using the new builder
@@ -26,9 +29,41 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
26
29
  model,
27
30
  tools,
28
31
  _contextManager: contextManager,
32
+ _cascade: cascade,
29
33
  };
30
34
 
35
+ // Self-optimization bookkeeping for the current top-level task (opt-in).
36
+ let _optTask = null; // { model, complexity, startedAt, tokensAtStart }
37
+
38
+ /** Record the outcome of the current task, if self-optimization is on. */
39
+ function recordOptOutcome(success) {
40
+ if (!cascade || !_optTask) return;
41
+ const inputDelta = state.tokenUsage.input - _optTask.tokensAtStart.input;
42
+ const outputDelta = state.tokenUsage.output - _optTask.tokensAtStart.output;
43
+ try {
44
+ cascade.recordOutcome({
45
+ model: _optTask.model,
46
+ success,
47
+ latencyMs: Date.now() - _optTask.startedAt,
48
+ inputTokens: inputDelta >= 0 ? inputDelta : 0,
49
+ outputTokens: outputDelta >= 0 ? outputDelta : 0,
50
+ complexity: _optTask.complexity,
51
+ task: _optTask.task,
52
+ });
53
+ } catch { /* best-effort */ }
54
+ _optTask = null;
55
+ }
56
+
31
57
  async function* run(userMessage, options = {}) {
58
+ const depth = (options._depth || 0);
59
+
60
+ // Guard against runaway tool-call recursion
61
+ if (depth >= MAX_TOOL_RECURSION_DEPTH) {
62
+ yield { type: 'error', message: `Max tool recursion depth (${MAX_TOOL_RECURSION_DEPTH}) reached. Stopping to prevent infinite loop.` };
63
+ yield { type: 'stop', reason: 'max_recursion' };
64
+ return;
65
+ }
66
+
32
67
  // Add user message (skip for continuation turns)
33
68
  if (userMessage && !options.continuation) {
34
69
  state.messages = contextManager.addMessage(state.messages, {
@@ -36,6 +71,22 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
36
71
  content: userMessage,
37
72
  });
38
73
  state.turnCount++;
74
+
75
+ // Opt-in cost-cascade routing: pick the cheapest good-enough model
76
+ // for this task and record outcome bookkeeping. Default path (no
77
+ // cascade) leaves state.model exactly as constructed.
78
+ if (cascade) {
79
+ const route = cascade.decide(userMessage);
80
+ state.model = route.model;
81
+ state._lastRoute = route;
82
+ _optTask = {
83
+ model: route.model,
84
+ complexity: route.complexity,
85
+ startedAt: Date.now(),
86
+ tokensAtStart: { input: state.tokenUsage.input, output: state.tokenUsage.output },
87
+ task: String(userMessage).slice(0, 120),
88
+ };
89
+ }
39
90
  }
40
91
 
41
92
  // Check max turns
@@ -53,14 +104,16 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
53
104
 
54
105
  yield { type: 'stream_request_start', turn: state.turnCount };
55
106
 
56
- // Detect provider and call API
57
- const provider = detectProvider(model);
107
+ // Detect provider and call API. Use state.model so an opt-in cascade
108
+ // pick takes effect; defaults to the constructed model otherwise.
109
+ const activeModel = state.model || model;
110
+ const provider = detectProvider(activeModel);
58
111
  let response;
59
112
 
60
113
  try {
61
- if (settings.stream !== false) {
114
+ if (settings.stream !== false && provider === 'anthropic') {
62
115
  // Streaming mode
63
- response = await callApiStreaming(provider, model, state, tools.list(), settings);
116
+ response = await callApiStreaming(provider, activeModel, state, tools.list(), settings);
64
117
  const collectedContent = [];
65
118
  let currentText = '';
66
119
  let currentThinking = '';
@@ -87,9 +140,10 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
87
140
  response = response.accumulated;
88
141
  } else {
89
142
  // Non-streaming mode
90
- response = await callApi(provider, model, state, tools.list(), settings);
143
+ response = await callApi(provider, activeModel, state, tools.list(), settings);
91
144
  }
92
145
  } catch (err) {
146
+ recordOptOutcome(false);
93
147
  yield { type: 'error', message: err.message };
94
148
  return;
95
149
  }
@@ -180,7 +234,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
180
234
  state.messages.push({ role: 'user', content: toolResults });
181
235
 
182
236
  // Recursive: continue the loop after tool execution
183
- yield* run(null, { continuation: true });
237
+ yield* run(null, { continuation: true, _depth: depth + 1 });
184
238
  return;
185
239
  }
186
240
 
@@ -193,11 +247,14 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
193
247
  role: 'user',
194
248
  content: '[System: A hook prevented stopping. Please continue with the task.]',
195
249
  });
196
- yield* run(null, { continuation: true });
250
+ yield* run(null, { continuation: true, _depth: depth + 1 });
197
251
  return;
198
252
  }
199
253
  }
200
254
 
255
+ // Task completed normally — record a successful outcome (opt-in).
256
+ recordOptOutcome(true);
257
+
201
258
  yield { type: 'stop', reason: response.stop_reason || 'end_turn' };
202
259
  }
203
260
 
@@ -223,8 +280,8 @@ async function callApiStreaming(provider, model, state, toolDefs, settings) {
223
280
  }
224
281
 
225
282
  async function callAnthropic(model, state, toolDefs, settings, stream) {
226
- const apiKey = process.env.ANTHROPIC_API_KEY;
227
- if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
283
+ const apiKey = readApiKey('ANTHROPIC_API_KEY');
284
+ if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set. Export it in your environment before running.');
228
285
 
229
286
  const body = {
230
287
  model,
@@ -240,7 +297,8 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
240
297
  body.thinking = { type: 'enabled', budget_tokens: settings.thinkingBudget || 10000 };
241
298
  }
242
299
 
243
- const res = await fetch('https://api.anthropic.com/v1/messages', {
300
+ const res = await fetch((process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '') + '/v1/messages', {
301
+ redirect: 'error',
244
302
  method: 'POST',
245
303
  headers: {
246
304
  'Content-Type': 'application/json',
@@ -252,7 +310,7 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
252
310
 
253
311
  if (!res.ok) {
254
312
  const err = await res.text();
255
- throw new Error(`Anthropic API error ${res.status}: ${err}`);
313
+ throw new Error(`Anthropic API error ${res.status}`);
256
314
  }
257
315
 
258
316
  if (stream) {
@@ -275,24 +333,29 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
275
333
  }
276
334
 
277
335
  async function callOpenAI(model, state, toolDefs, settings, stream) {
278
- const apiKey = process.env.OPENAI_API_KEY;
279
- if (!apiKey) throw new Error('OPENAI_API_KEY not set');
336
+ const apiKey = readApiKey('OPENAI_API_KEY');
337
+ if (!apiKey) throw new Error('OPENAI_API_KEY is not set. Export it in your environment before running.');
280
338
 
281
339
  const messages = [];
282
- if (state.systemPrompt) {
283
- messages.push({ role: 'system', content: state.systemPrompt });
284
- }
340
+ if (state.systemPrompt) messages.push({ role: 'system', content: state.systemPrompt });
285
341
  for (const msg of state.messages) {
286
342
  if (typeof msg.content === 'string') {
287
343
  messages.push({ role: msg.role, content: msg.content });
288
344
  } else if (Array.isArray(msg.content)) {
289
- for (const block of msg.content) {
290
- if (block.type === 'tool_result') {
291
- messages.push({
292
- role: 'tool',
345
+ if (msg.role === 'assistant') {
346
+ const calls = msg.content.filter(b => b.type === 'tool_use').map(b => ({
347
+ id: b.id, type: 'function',
348
+ function: { name: b.name, arguments: JSON.stringify(b.input || {}) }
349
+ }));
350
+ messages.push({ role: 'assistant',
351
+ content: msg.content.filter(b => b.type === 'text').map(b => b.text).join('') || null,
352
+ ...(calls.length && { tool_calls: calls }) });
353
+ } else {
354
+ for (const block of msg.content) {
355
+ if (block.type === 'tool_result') messages.push({ role: 'tool',
293
356
  tool_call_id: block.tool_use_id,
294
- content: block.content,
295
- });
357
+ content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content) });
358
+ else if (block.type === 'text') messages.push({ role: msg.role, content: block.text });
296
359
  }
297
360
  }
298
361
  }
@@ -311,6 +374,7 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
311
374
 
312
375
  const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
313
376
  const res = await fetch(`${baseUrl}/chat/completions`, {
377
+ redirect: 'error',
314
378
  method: 'POST',
315
379
  headers: {
316
380
  'Content-Type': 'application/json',
@@ -321,7 +385,7 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
321
385
 
322
386
  if (!res.ok) {
323
387
  const err = await res.text();
324
- throw new Error(`OpenAI API error ${res.status}: ${err}`);
388
+ throw new Error(`OpenAI API error ${res.status}`);
325
389
  }
326
390
 
327
391
  const data = await res.json();
@@ -329,8 +393,8 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
329
393
  }
330
394
 
331
395
  async function callGoogle(model, state, toolDefs, settings, stream) {
332
- const apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
333
- if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY not set');
396
+ const apiKey = readApiKey('GOOGLE_API_KEY', 'GEMINI_API_KEY');
397
+ if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY is not set. Export one in your environment before running.');
334
398
 
335
399
  const contents = [];
336
400
  for (const msg of state.messages) {
@@ -204,6 +204,24 @@ export function listProviders() {
204
204
  }));
205
205
  }
206
206
 
207
+ /**
208
+ * Read an API key from the environment, in order of preference.
209
+ *
210
+ * Surrounding whitespace is trimmed — keys routinely arrive from .env files or
211
+ * command substitution with a trailing newline — and a whitespace-only value is
212
+ * treated as unset, so the next name is tried. No format validation: the
213
+ * provider is what authenticates the key.
214
+ * @param {...string} envKeys - env var names to try, most preferred first
215
+ * @returns {string} the trimmed key, or '' if none is configured
216
+ */
217
+ export function readApiKey(...envKeys) {
218
+ for (const envKey of envKeys) {
219
+ const value = (process.env[envKey] || '').trim();
220
+ if (value) return value;
221
+ }
222
+ return '';
223
+ }
224
+
207
225
  /**
208
226
  * Check which providers have API keys configured.
209
227
  * @returns {Array<{ id: string, name: string, configured: boolean }>}
@@ -212,7 +230,7 @@ export function checkProviderKeys() {
212
230
  return Object.entries(PROVIDERS).map(([key, p]) => ({
213
231
  id: key,
214
232
  name: p.name,
215
- configured: !!(process.env[p.envKey] || (p.altEnvKey && process.env[p.altEnvKey])),
233
+ configured: !!readApiKey(p.envKey, ...(p.altEnvKey ? [p.altEnvKey] : [])),
216
234
  }));
217
235
  }
218
236