aegiscode 6.3.1 → 6.4.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.
@@ -0,0 +1,323 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The AEGIS account credential store for the terminal host.
5
+ *
6
+ * Before this module the CLI had exactly one way to be given a key: an
7
+ * `AEGIS_API_KEY` environment variable, checked as `process.env.AEGIS_API_KEY`
8
+ * at five separate call sites. A shell export does not survive a new terminal,
9
+ * a reboot, an SSH session or a desktop launcher, so every one of those turned
10
+ * the product back into "no key — export one" with no in-band way to fix it:
11
+ * `/byok-set` stores *provider* keys server-side and `/login` is one of the
12
+ * reference commands this host deliberately marks unavailable. The only
13
+ * credential the CLI could not accept was its own.
14
+ *
15
+ * Resolution order, first hit wins:
16
+ *
17
+ * 1. `AEGIS_API_KEY` — the environment, so CI and `--key` keep working and
18
+ * nothing written here can shadow an explicit export.
19
+ * 2. `credentials.json` in the data dir, mode 0600. The writable store.
20
+ * 3. `config.json`'s `aegiscloud.api_key` / `memory.token` — the shape an
21
+ * earlier AEGIS CLI left in the same data dir. Read, never written, and
22
+ * never deleted: it is another product's file and the user's key is in
23
+ * it. When one is found it is *also* copied into credentials.json so the
24
+ * next run reads the 0600 copy, and `/cloud status` says the plaintext
25
+ * copy is still there rather than silently leaving it.
26
+ *
27
+ * The key is never printed in full by anything in this package; callers mask it
28
+ * with format.js's maskKey.
29
+ */
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+ const { aegisDir, configPath } = require('./config.js');
34
+
35
+ const KEY_ENV = 'AEGIS_API_KEY';
36
+ /** Optional override for the memory token (cloud sync's own credential). */
37
+ const MEMORY_ENV = 'AEGIS_MEMORY_TOKEN';
38
+ const CREDENTIALS_FILE = 'credentials.json';
39
+ const FILE_MODE = 0o600;
40
+ const DIR_MODE = 0o700;
41
+
42
+ function credentialsPath() {
43
+ return path.join(aegisDir(), CREDENTIALS_FILE);
44
+ }
45
+
46
+ /**
47
+ * Accept the key in the shapes a user actually pastes it.
48
+ *
49
+ * Copy-paste from a dashboard, a `.env` line, a shell profile or a chat message
50
+ * are all realistic — and pasting `AEGIS_API_KEY=aegis_…` or `"aegis_…"` into a
51
+ * prompt that stores the literal string produces an auth failure the user
52
+ * cannot see the cause of, because the key *looks* right in the status line.
53
+ */
54
+ function normalizeApiKey(raw) {
55
+ let s = String(raw == null ? '' : raw).trim();
56
+ if (!s) return '';
57
+ s = s.replace(/^export\s+/i, '').trim();
58
+ const assignment = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/s.exec(s);
59
+ if (assignment) s = assignment[1].trim();
60
+ s = s.replace(/^["']|["']$/g, '').trim();
61
+ s = s.replace(/^Bearer\s+/i, '').trim();
62
+ return s;
63
+ }
64
+
65
+ /**
66
+ * Shape check only — no network. Catches the two mistakes that are structural
67
+ * (an empty paste, and a wrapped/truncated multi-line paste) so the caller can
68
+ * refuse before spending a verification round trip.
69
+ */
70
+ function validateApiKey(raw) {
71
+ const key = normalizeApiKey(raw);
72
+ if (!key) return { ok: false, key, reason: 'empty', message: 'no key given' };
73
+ if (/\s/.test(key)) {
74
+ return {
75
+ ok: false,
76
+ key,
77
+ reason: 'whitespace',
78
+ message: 'that looks like more than one word — paste just the key',
79
+ };
80
+ }
81
+ if (key.length < 16) {
82
+ return {
83
+ ok: false,
84
+ key,
85
+ reason: 'too short',
86
+ message: `that key is ${key.length} characters — AEGIS keys are longer than that`,
87
+ };
88
+ }
89
+ return { ok: true, key, reason: null, message: null };
90
+ }
91
+
92
+ /** The stored credential object, or {} — never throws on a missing/corrupt file. */
93
+ function readCredentials() {
94
+ try {
95
+ const parsed = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
96
+ if (parsed && typeof parsed === 'object') return parsed;
97
+ } catch {}
98
+ return {};
99
+ }
100
+
101
+ /**
102
+ * Merge a patch into the store, creating it 0600 (and tightening an existing
103
+ * file that is wider — a key file that a previous run left 0644 is exactly the
104
+ * leak this file exists to avoid).
105
+ */
106
+ function writeCredentials(patch) {
107
+ const next = { ...readCredentials(), ...patch, version: 1 };
108
+ try {
109
+ fs.mkdirSync(aegisDir(), { recursive: true, mode: DIR_MODE });
110
+ const target = credentialsPath();
111
+ const tmp = target + '.tmp';
112
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: FILE_MODE });
113
+ fs.renameSync(tmp, target);
114
+ try {
115
+ fs.chmodSync(target, FILE_MODE);
116
+ } catch {}
117
+ } catch (e) {
118
+ if (process.env.AEGIS_HIST_DEBUG) console.error('[credentials] write failed:', e);
119
+ return { ok: false, error: e, credentials: next };
120
+ }
121
+ return { ok: true, credentials: next };
122
+ }
123
+
124
+ /**
125
+ * The key an earlier AEGIS CLI wrote into config.json (the `aegiscloud` /
126
+ * `memory` blocks are not ours — config.js cannot produce them). Read-only.
127
+ */
128
+ function readLegacyConfig() {
129
+ try {
130
+ const parsed = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
131
+ if (!parsed || typeof parsed !== 'object') return {};
132
+ const cloud = parsed.aegiscloud && typeof parsed.aegiscloud === 'object' ? parsed.aegiscloud : {};
133
+ const memory = parsed.memory && typeof parsed.memory === 'object' ? parsed.memory : {};
134
+ return {
135
+ apiKey: normalizeApiKey(cloud.api_key),
136
+ memoryToken: String(memory.token || '').trim(),
137
+ syncConversations: cloud.syncConversations === true ? true : undefined,
138
+ lastVerified: cloud.lastVerified || null,
139
+ memorySubscribed: memory.subscribed === true ? true : undefined,
140
+ };
141
+ } catch {
142
+ return {};
143
+ }
144
+ }
145
+
146
+ /** True when config.json still carries a plaintext copy of the account key. */
147
+ function legacyKeyOnDisk() {
148
+ return !!readLegacyConfig().apiKey;
149
+ }
150
+
151
+ /**
152
+ * Copy a legacy key/token into the 0600 store, once, without touching the file
153
+ * it came from. Returns which fields were adopted so the caller can say so.
154
+ */
155
+ function adoptLegacy() {
156
+ const legacy = readLegacyConfig();
157
+ const creds = readCredentials();
158
+ const patch = {};
159
+ if (legacy.apiKey && !creds.aegisApiKey) patch.aegisApiKey = legacy.apiKey;
160
+ if (legacy.memoryToken && !creds.memoryToken) patch.memoryToken = legacy.memoryToken;
161
+ if (!Object.keys(patch).length) return { adopted: [] };
162
+ const written = writeCredentials({ ...patch, adoptedFrom: configPath() });
163
+ if (!written.ok) return { adopted: [] };
164
+ return { adopted: Object.keys(patch) };
165
+ }
166
+
167
+ /**
168
+ * The key to use, and where it came from.
169
+ *
170
+ * @returns {{key:string, source:'env'|'credentials'|'config'|'none', from:string}}
171
+ */
172
+ function resolveApiKey(o = {}) {
173
+ const env = o.env || process.env;
174
+ const fromEnv = normalizeApiKey(env && env[KEY_ENV]);
175
+ if (fromEnv) return { key: fromEnv, source: 'env', from: KEY_ENV };
176
+
177
+ const creds = readCredentials();
178
+ const stored = normalizeApiKey(creds.aegisApiKey);
179
+ if (stored) return { key: stored, source: 'credentials', from: credentialsPath() };
180
+
181
+ const legacy = readLegacyConfig().apiKey;
182
+ if (legacy) return { key: legacy, source: 'config', from: configPath() };
183
+
184
+ return { key: '', source: 'none', from: '' };
185
+ }
186
+
187
+ /** Whether any credential is available (env, store or legacy config). */
188
+ function hasApiKey(o = {}) {
189
+ return !!resolveApiKey(o).key;
190
+ }
191
+
192
+ /**
193
+ * Persist a key.
194
+ *
195
+ * @returns {{ok:boolean, key:string, path:string, error?:Error, message?:string}}
196
+ */
197
+ function saveApiKey(raw) {
198
+ const v = validateApiKey(raw);
199
+ if (!v.ok) return { ok: false, key: v.key, path: credentialsPath(), message: v.message };
200
+ const written = writeCredentials({ aegisApiKey: v.key, savedAt: new Date().toISOString() });
201
+ if (!written.ok) {
202
+ return {
203
+ ok: false,
204
+ key: v.key,
205
+ path: credentialsPath(),
206
+ error: written.error,
207
+ message: `could not write ${credentialsPath()}`,
208
+ };
209
+ }
210
+ return { ok: true, key: v.key, path: credentialsPath() };
211
+ }
212
+
213
+ /**
214
+ * Remove the stored key (and nothing else — the memory token stays, since a
215
+ * key rotation should not silently unsubscribe cloud memory). config.json is
216
+ * never touched: another product writes it.
217
+ */
218
+ function clearApiKey() {
219
+ const had = !!normalizeApiKey(readCredentials().aegisApiKey);
220
+ const written = writeCredentials({ aegisApiKey: '', adoptedFrom: '' });
221
+ return { cleared: had, ok: written.ok, path: credentialsPath() };
222
+ }
223
+
224
+ /** The memory token cloud sync authenticates with, from store or legacy. */
225
+ function resolveMemoryToken(o = {}) {
226
+ const env = o.env || process.env;
227
+ const fromEnv = String((env && env[MEMORY_ENV]) || '').trim();
228
+ if (fromEnv) return { token: fromEnv, source: 'env' };
229
+ const creds = readCredentials();
230
+ const stored = String(creds.memoryToken || '').trim();
231
+ if (stored) return { token: stored, source: 'credentials' };
232
+ const legacy = readLegacyConfig().memoryToken;
233
+ if (legacy) return { token: legacy, source: 'config' };
234
+ return { token: '', source: 'none' };
235
+ }
236
+
237
+ function saveMemoryToken(token, extra = {}) {
238
+ return writeCredentials({ memoryToken: String(token || '').trim(), ...extra });
239
+ }
240
+
241
+ function clearMemoryToken() {
242
+ return writeCredentials({ memoryToken: '', memorySubscribed: false });
243
+ }
244
+
245
+ /**
246
+ * Everything a status screen or a `doctor` line needs about the credential,
247
+ * without ever exposing the key itself.
248
+ */
249
+ function keyStatus(o = {}) {
250
+ const { key, source, from } = resolveApiKey(o);
251
+ const creds = readCredentials();
252
+ let mode = null;
253
+ try {
254
+ mode = fs.statSync(credentialsPath()).mode & 0o777;
255
+ } catch {}
256
+ return {
257
+ configured: !!key,
258
+ key,
259
+ source,
260
+ from,
261
+ path: credentialsPath(),
262
+ fileMode: mode == null ? null : '0' + mode.toString(8),
263
+ stored: !!normalizeApiKey(creds.aegisApiKey),
264
+ legacyPlaintext: legacyKeyOnDisk(),
265
+ verifiedAt: creds.verifiedAt || null,
266
+ account: creds.account || null,
267
+ memoryToken: !!resolveMemoryToken(o).token,
268
+ memorySource: resolveMemoryToken(o).source,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Client options for `createClient`: the resolved key plus the stored memory
274
+ * token, so a restart does not re-exchange for a token it already has.
275
+ */
276
+ function clientOptions(o = {}) {
277
+ const { key } = resolveApiKey(o);
278
+ const { token } = resolveMemoryToken(o);
279
+ const opts = {};
280
+ if (key) opts.apiKey = key;
281
+ if (token) opts.memoryToken = token;
282
+ return opts;
283
+ }
284
+
285
+ /** Human label for a resolution source, for status lines. */
286
+ const SOURCE_LABEL = {
287
+ env: `$${KEY_ENV}`,
288
+ credentials: 'saved key file',
289
+ config: 'config.json (aegis CLI)',
290
+ none: 'not set',
291
+ };
292
+
293
+ function sourceLabel(source) {
294
+ return SOURCE_LABEL[source] || SOURCE_LABEL.none;
295
+ }
296
+
297
+ /** One line telling the user how to supply a key, used by every error path. */
298
+ const HOW_TO_SET = 'run `aegiscode login` (or /key inside a session) to save one';
299
+
300
+ module.exports = {
301
+ KEY_ENV,
302
+ MEMORY_ENV,
303
+ CREDENTIALS_FILE,
304
+ credentialsPath,
305
+ normalizeApiKey,
306
+ validateApiKey,
307
+ readCredentials,
308
+ writeCredentials,
309
+ readLegacyConfig,
310
+ legacyKeyOnDisk,
311
+ adoptLegacy,
312
+ resolveApiKey,
313
+ hasApiKey,
314
+ saveApiKey,
315
+ clearApiKey,
316
+ resolveMemoryToken,
317
+ saveMemoryToken,
318
+ clearMemoryToken,
319
+ keyStatus,
320
+ clientOptions,
321
+ sourceLabel,
322
+ HOW_TO_SET,
323
+ };
package/src/history.js CHANGED
@@ -35,7 +35,6 @@ function ensureHistoryDir() {
35
35
  */
36
36
  function appendHistory({ sessionId, prompt, reply, status, usage }) {
37
37
  try {
38
- ensureHistoryDir();
39
38
  const entry = {
40
39
  ts: new Date().toISOString(),
41
40
  sessionId,
@@ -54,14 +53,40 @@ function appendHistory({ sessionId, prompt, reply, status, usage }) {
54
53
  : { input: estimateTokens(prompt), output: estimateTokens(reply || ''), real: false },
55
54
  };
56
55
  if (usage && typeof usage.costUsd === 'number') entry.costUsd = usage.costUsd;
56
+ return appendHistoryEntries([entry]);
57
+ } catch (e) {
58
+ // Persistence is best-effort; never crash the session over it.
59
+ if (process.env.AEGIS_HIST_DEBUG) console.error('[history] write failed:', e);
60
+ return 0;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Append a batch of already-shaped entries in ONE read/trim/write pass.
66
+ *
67
+ * `appendHistory` re-reads and rewrites the whole file per exchange, which is
68
+ * fine for one turn at a time but quadratic for a caller with a list of them —
69
+ * importing 50 pulled sessions would rewrite the file 50 times, each pass
70
+ * re-parsing everything the previous pass just wrote. The single writer (and
71
+ * therefore the single place the file format is defined) stays here.
72
+ *
73
+ * @returns {number} entries written
74
+ */
75
+ function appendHistoryEntries(entries) {
76
+ const list = (Array.isArray(entries) ? entries : []).filter(Boolean);
77
+ if (!list.length) return 0;
78
+ try {
79
+ ensureHistoryDir();
57
80
  const p = historyPath();
58
81
  const prev = fs.existsSync(p) ? fs.readFileSync(p, 'utf8').split('\n').filter(Boolean) : [];
59
- const lines = [...prev, JSON.stringify(entry)];
82
+ const lines = [...prev, ...list.map((e) => JSON.stringify(e))];
60
83
  const trimmed = lines.slice(Math.max(0, lines.length - HISTORY_LIMIT));
61
84
  fs.writeFileSync(p, trimmed.join('\n') + '\n');
85
+ return list.length;
62
86
  } catch (e) {
63
87
  // Persistence is best-effort; never crash the session over it.
64
88
  if (process.env.AEGIS_HIST_DEBUG) console.error('[history] write failed:', e);
89
+ return 0;
65
90
  }
66
91
  }
67
92
 
@@ -79,6 +104,11 @@ function readEntries() {
79
104
  }
80
105
  }
81
106
 
107
+ /** Every history record, oldest first. Public name for other modules. */
108
+ function readHistoryEntries() {
109
+ return readEntries();
110
+ }
111
+
82
112
  /** Newest-first list of own sessions, one per distinct sessionId. */
83
113
  function readOwnSessions(limit = 8) {
84
114
  const entries = readEntries();
@@ -192,6 +222,8 @@ module.exports = {
192
222
  historyPath,
193
223
  ensureHistoryDir,
194
224
  appendHistory,
225
+ appendHistoryEntries,
226
+ readHistoryEntries,
195
227
  readOwnSessions,
196
228
  readSessionTranscript,
197
229
  sessionHistoryEntries,
package/src/overlays.js CHANGED
@@ -177,27 +177,38 @@ function renderModelPicker(models, sel = 0, width = 80, height = 24, current = n
177
177
  // ── Effort picker overlay (/effort) ──────────────────────────────────────────
178
178
 
179
179
  const EFFORT_LEVELS = [
180
- { label: 'Low', note: 'Fastest, most efficient' },
180
+ { label: 'Auto', value: null, note: 'Sized per turn from the ask (default)' },
181
+ { label: 'Low', note: 'Fastest, cheapest budget' },
181
182
  { label: 'Medium', note: 'Balanced' },
182
- { label: 'High', note: 'Highest quality, slowest' },
183
+ { label: 'High', note: 'Highest budget, slowest' },
183
184
  ];
184
185
 
186
+ // The picker's order reduced to the values a selection means, so the row a user
187
+ // picks and the value the session stores can never come from two different
188
+ // lists (commands.js validates against this, chatflow.js resolves the overlay
189
+ // through it). `null` is "auto" — no rung pinned.
190
+ const EFFORT_VALUES = EFFORT_LEVELS.map((lv) => (lv.value === undefined ? lv.label.toLowerCase() : lv.value));
191
+
185
192
  /**
186
193
  * @param {number} sel selected index
187
194
  * @param {number} width
188
- * @param {string|null} current the currently-selected effort level
195
+ * @param {string|null} current the currently-selected effort level (null = auto)
189
196
  * @param {Array} [levels] override the level table
190
197
  */
191
198
  function renderEffortPicker(sel = 0, width = 80, current = null, levels = EFFORT_LEVELS) {
192
199
  const lines = [];
193
200
  lines.push(blank(width));
194
201
  lines.push([span('', ' '), span(C.white + BOLD, 'Select effort'), span(BOLD_OFF, '')]);
195
- lines.push([span('', ' '), span(C.gray, 'Controls how much reasoning the model puts into each turn.')]);
202
+ lines.push([span('', ' '), span(C.gray, 'Sets the token budget the pool sizes each turn from.')]);
196
203
  lines.push(blank(width));
197
204
  for (let i = 0; i < levels.length; i++) {
198
205
  const lv = levels[i];
199
206
  const active = i === sel;
200
- const cur = current != null && String(current).toLowerCase() === String(lv.label).toLowerCase();
207
+ // `current == null` is auto, which is the first row's own value — matching
208
+ // on the label alone would never mark the default as the current choice.
209
+ const cur = current == null
210
+ ? lv.value === null
211
+ : String(current).toLowerCase() === String(lv.label).toLowerCase();
201
212
  const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
202
213
  const nameSpan = active ? span(C.lavender, lv.label) : span(C.white, lv.label);
203
214
  const mark = cur ? span(C.green, ' ' + GLYPH.check) : span('', '');
@@ -283,4 +294,6 @@ module.exports = {
283
294
  renderModelPicker,
284
295
  renderEffortPicker,
285
296
  renderResumeList,
297
+ EFFORT_LEVELS,
298
+ EFFORT_VALUES,
286
299
  };
package/src/panels.js CHANGED
@@ -202,7 +202,9 @@ function buildStatus(state, ctx = {}) {
202
202
  add('Working directory', s.cwd);
203
203
  add('Home', s.home);
204
204
  add('Model', s.model);
205
- add('Effort', s.effort);
205
+ // `null` = auto: the pool sizes each turn from the ask, so the row says so
206
+ // rather than vanishing (add() skips empty values).
207
+ add('Effort', s.effort || 'auto');
206
208
  if (s.thinking != null) add('Thinking', s.thinking ? 'on' : 'off');
207
209
  if (s.stream != null) add('Streaming', s.stream === false ? 'off' : 'on');
208
210
  if (s.vim != null) add('Vim keymap', s.vim ? 'on' : 'off');
package/src/screens.js CHANGED
@@ -36,6 +36,7 @@ const { welcomeArtParts } = require('./art.js');
36
36
  const { renderDiffPreview } = require('./markdown.js');
37
37
  const render = require('./render.js');
38
38
  const { updateConfig, configExists } = require('./config.js');
39
+ const credentials = require('./credentials.js');
39
40
 
40
41
  const VERSION = require('../package.json').version;
41
42
 
@@ -387,6 +388,115 @@ async function showWelcome(ctx, firstRun = true) {
387
388
  }
388
389
  }
389
390
 
391
+ // ── the account key ──────────────────────────────────────────────────────────
392
+
393
+ const KEY_URL = 'https://aegiscloud.org';
394
+
395
+ /**
396
+ * The key screen's lines. Pure, so what the user is told is asserted directly.
397
+ *
398
+ * `value` is echoed back as bullets: this is the one screen in the product
399
+ * where the thing being typed is a secret, and a screen that echoes it would
400
+ * put the key in a scrollback buffer, a screenshot and a screen share.
401
+ */
402
+ function keyLines(ctx, cols, { value = '', error = null, verify = false } = {}) {
403
+ const t = themeOf(ctx);
404
+ const lines = [];
405
+ lines.push([span(t.gold, '─'.repeat(Math.max(1, cols)))]);
406
+ lines.push([span(t.white + BOLD, 'Connect your AEGIS account')]);
407
+ lines.push([span('', '')]);
408
+ for (const l of wrapped(
409
+ `Paste an API key to use AEGIS Cloud. Get one free at ${KEY_URL}. ` +
410
+ 'It is stored in your user config directory with owner-only permissions, so later launches and scripts pick it up without an export.',
411
+ cols,
412
+ t.white
413
+ )) {
414
+ lines.push(l);
415
+ }
416
+ lines.push([span('', '')]);
417
+ lines.push([
418
+ span(t.lavender, GLYPH.cursor),
419
+ span(t.gray, ' API key: '),
420
+ span(t.white, '•'.repeat(String(value).length)),
421
+ span(verify ? t.cyan : t.gray, verify ? ' verifying…' : ''),
422
+ ]);
423
+ if (error) lines.push([span(t.coral, ` ${error}`)]);
424
+ lines.push([span('', '')]);
425
+ lines.push([
426
+ span(t.gray, ` ${GLYPH.check} `),
427
+ span(t.gray, 'Enter to save'),
428
+ span(t.gray, ' · '),
429
+ span(t.gray, 'Esc to skip'),
430
+ ]);
431
+ return lines;
432
+ }
433
+
434
+ /**
435
+ * Ask for the account key. Resolves `{key}` when submitted, `{skipped:true}`
436
+ * when the user declines, `{exit:true}` on ctrl+c.
437
+ *
438
+ * `submit(key)` is the caller's verification+persistence step; its rejection
439
+ * message is shown on the screen and the user stays on it, because the failure
440
+ * mode this prevents is a key that is accepted into the config while the
441
+ * account behind it rejects every call.
442
+ */
443
+ async function requestApiKey(ctx, o = {}) {
444
+ const submit = o.submit || (async () => ({ ok: true }));
445
+ const { cols } = getSize();
446
+ let value = '';
447
+ let error = null;
448
+ let verify = false;
449
+ const paintScreen = () => paint(keyLines(ctx, cols, { value, error, verify }));
450
+ paintScreen();
451
+
452
+ for (;;) {
453
+ const key = await nextKey();
454
+ if (key.name === KEY.ENTER) {
455
+ if (!value.trim()) return { skipped: true };
456
+ verify = true;
457
+ error = null;
458
+ paintScreen();
459
+ let res;
460
+ try {
461
+ res = await submit(value);
462
+ } catch (e) {
463
+ res = { ok: false, message: (e && e.message) || String(e) };
464
+ }
465
+ verify = false;
466
+ if (res && res.ok === false) {
467
+ error = res.message || 'that key was refused';
468
+ paintScreen();
469
+ continue;
470
+ }
471
+ return { key: value, result: res };
472
+ }
473
+ if (key.name === KEY.ESC) return { skipped: true };
474
+ if (key.name === KEY.CTRL_C || key.name === KEY.CTRL_D) return { exit: true };
475
+ if (key.name === KEY.BACKSPACE) {
476
+ value = value.slice(0, -1);
477
+ error = null;
478
+ paintScreen();
479
+ continue;
480
+ }
481
+ if (key.name === 'char') {
482
+ const ch = key.ch;
483
+ if (ch && ch >= ' ') {
484
+ value += ch;
485
+ error = null;
486
+ paintScreen();
487
+ }
488
+ continue;
489
+ }
490
+ // A paste arrives as its own event (events.js rushes multi-char reads);
491
+ // without this a pasted key is dropped on the floor.
492
+ if (key.name === 'paste' && key.text) {
493
+ value = (value + String(key.text)).replace(/\s+/g, '');
494
+ error = null;
495
+ paintScreen();
496
+ }
497
+ }
498
+ }
499
+
390
500
  // ── the sequence ─────────────────────────────────────────────────────────────
391
501
 
392
502
  /**
@@ -398,8 +508,12 @@ async function showWelcome(ctx, firstRun = true) {
398
508
  * @param {() => boolean} [o.seen] an explicit "has run before" probe; defaults
399
509
  * to the config file's existence
400
510
  * @param {(patch:object)=>void} [o.save] persist patch; defaults to updateConfig
401
- * @returns {Promise<{ok:boolean, firstRun:boolean, themeIndex:number}>} `ok`
402
- * is false when the user declined the trust check or asked to exit.
511
+ * @param {() => boolean} [o.needsKey] true when no account key is configured —
512
+ * the key screen is shown only then
513
+ * @param {(key:string)=>Promise<object>} [o.submitKey] verify + persist a key
514
+ * @returns {Promise<{ok:boolean, firstRun:boolean, themeIndex:number,
515
+ * key:{set:boolean, skipped:boolean}}>} `ok` is false when the user
516
+ * declined the trust check or asked to exit.
403
517
  */
404
518
  async function runOnboarding(ctx, o = {}) {
405
519
  const seen = o.seen || configExists;
@@ -407,8 +521,29 @@ async function runOnboarding(ctx, o = {}) {
407
521
  // Injectable so the *sequence* — which screens run, in what order, and what is
408
522
  // persisted — can be asserted without a terminal. The screens themselves are
409
523
  // tested directly through their pure line-builders.
410
- const ui = o.ui || { showTrustCheck, showThemePicker, showWelcome };
411
- if (o.continue) return { ok: true, firstRun: false, themeIndex: ctx.themeIndex };
524
+ const ui = {
525
+ showTrustCheck,
526
+ showThemePicker,
527
+ showWelcome,
528
+ requestApiKey: (c, opts) =>
529
+ requestApiKey(c, { submit: o.submitKey || (async () => ({ ok: true })), ...opts }),
530
+ // An injected `ui` overrides the screens it names and inherits the rest, so
531
+ // a caller testing the sequence does not have to supply a key screen it
532
+ // never wants to exercise.
533
+ ...(o.ui || {}),
534
+ };
535
+ // No key is the one state where the session cannot do anything at all, so it
536
+ // is asked for in-band rather than left to a shell export the user has to
537
+ // discover. Default reads the credential store so a caller that forgets to
538
+ // pass it still gets the right behaviour.
539
+ const needsKey = o.needsKey || (() => !credentials.hasApiKey());
540
+ // …but only where a question can actually be asked. Without this a library
541
+ // caller with no TTY reaches a screen that waits on a key queue nothing will
542
+ // ever feed — a hang instead of a missing credential.
543
+ const canPrompt = o.canPrompt || (() => !!(process.stdin && process.stdin.isTTY));
544
+ // Declared out here because both the sequence and its key step report on it.
545
+ const key = { set: false, skipped: false };
546
+ if (o.continue) return { ok: true, firstRun: false, themeIndex: ctx.themeIndex, key: { set: false, skipped: false } };
412
547
 
413
548
  // Onboarding runs *before* the session loop, and the session loop is what
414
549
  // normally attaches the key pump — so without this the first screen paints and
@@ -429,17 +564,29 @@ async function runOnboarding(ctx, o = {}) {
429
564
  // neither is ever shown again. Re-running this every launch greeted
430
565
  // returning users with "Let's get started." and discarded their session.
431
566
  const trusted = await ui.showTrustCheck(ctx);
432
- if (!trusted) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex };
567
+ if (!trusted) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex, key };
433
568
  await ui.showThemePicker(ctx);
434
569
  save({ themeIndex: ctx.themeIndex, light: ctx.light });
570
+ if (await askKey()) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex, key };
435
571
  const welcome = await ui.showWelcome(ctx, true);
436
- if (welcome && welcome.exit) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex };
437
- return { ok: true, firstRun: true, themeIndex: ctx.themeIndex };
572
+ if (welcome && welcome.exit) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex, key };
573
+ return { ok: true, firstRun: true, themeIndex: ctx.themeIndex, key };
438
574
  }
439
575
 
576
+ if (await askKey()) return { ok: false, firstRun: false, themeIndex: ctx.themeIndex, key };
440
577
  const welcome = await ui.showWelcome(ctx, false);
441
- if (welcome && welcome.exit) return { ok: false, firstRun: false, themeIndex: ctx.themeIndex };
442
- return { ok: true, firstRun: false, themeIndex: ctx.themeIndex };
578
+ if (welcome && welcome.exit) return { ok: false, firstRun: false, themeIndex: ctx.themeIndex, key };
579
+ return { ok: true, firstRun: false, themeIndex: ctx.themeIndex, key };
580
+ }
581
+
582
+ /** @returns {Promise<boolean>} true when the user asked to exit. */
583
+ async function askKey() {
584
+ if (!needsKey() || !canPrompt()) return false;
585
+ const res = await ui.requestApiKey(ctx);
586
+ if (res && res.exit) return true;
587
+ if (res && res.key) key.set = true;
588
+ else key.skipped = true;
589
+ return false;
443
590
  }
444
591
  }
445
592
 
@@ -449,6 +596,7 @@ module.exports = {
449
596
  WHATS_NEW,
450
597
  trustLines,
451
598
  themePickerLines,
599
+ keyLines,
452
600
  applyTheme,
453
601
  welcomeLines,
454
602
  boxes,
@@ -458,6 +606,8 @@ module.exports = {
458
606
  wrapPlain,
459
607
  showTrustCheck,
460
608
  showThemePicker,
609
+ requestApiKey,
461
610
  showWelcome,
462
611
  runOnboarding,
612
+ KEY_URL,
463
613
  };