aegiscode 6.3.2 → 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.
package/src/commands.js CHANGED
@@ -58,6 +58,9 @@ const { summarizeTranscript, recapLine } = require('./summarize.js');
58
58
  const { sessionAccounting, accountingFromUsage, estimateTokens } = require('./tokens.js');
59
59
  const { transcriptToMarkdown, transcriptToJSON, writeExportFile, lastAssistantText } = require('./export.js');
60
60
  const { detectDevCommand, runDevServer } = require('./devrun.js');
61
+ const credentials = require('./credentials.js');
62
+ const cloudsync = require('./cloudsync.js');
63
+ const { maskKey } = require('./format.js');
61
64
  const { openUrl, URLS } = require('./system.js');
62
65
  const { sniffProject, buildAegisMd } = require('./init.js');
63
66
  const {
@@ -127,8 +130,117 @@ async function loadModels(c) {
127
130
  * The ÆGIS LLM routes are gated on the pooled brain being reachable — exactly
128
131
  * as the reference hides its ÆGIS routes when the backend is absent. `hidden`
129
132
  * is a function so visibility follows the environment at call time.
133
+ *
134
+ * It reads the *resolved* credential, not the environment: a key saved by
135
+ * `aegiscode login` lives in credentials.json, and checking only
136
+ * `process.env.AEGIS_API_KEY` here hid every AEGIS route from a user who had
137
+ * just successfully set one.
138
+ */
139
+ const cloudReady = () => credentials.hasApiKey();
140
+
141
+ // ── account key + cloud sync handlers ────────────────────────────────────────
142
+
143
+ /** The account-key panel body, shared by /key, /login and /cloud. */
144
+ function keyPanelLines(c, title = 'AEGIS account key') {
145
+ const st = c.keyStatus();
146
+ const lines = [
147
+ [span(C.gold + BOLD, title), span(BOLD_OFF, '')],
148
+ [span(C.gray, '─'.repeat(34))],
149
+ [
150
+ span(st.configured ? C.green : C.coral, ` ${st.configured ? '✔' : '⚠'}`),
151
+ span(C.white, ' key: '),
152
+ span(st.configured ? C.white : C.gray, st.configured ? maskKey(st.key) : 'not set'),
153
+ span(C.gray, ` (${credentials.sourceLabel(st.source)})`),
154
+ ],
155
+ [span(C.gray, ` stored in: ${st.path}${st.fileMode ? ` ${st.fileMode}` : ''}`)],
156
+ ];
157
+ if (st.verifiedAt) lines.push([span(C.gray, ` verified: ${st.verifiedAt}`)]);
158
+ if (st.account && (st.account.plan || st.account.email)) {
159
+ lines.push([
160
+ span(C.gray, ' account: '),
161
+ span(C.white, [st.account.plan, st.account.email].filter(Boolean).join(' · ')),
162
+ ]);
163
+ }
164
+ lines.push([
165
+ span(C.gray, ' memory: '),
166
+ span(st.memoryToken ? C.green : C.gray, st.memoryToken ? 'token held (cloud sync ready)' : 'no token yet'),
167
+ ]);
168
+ if (!st.configured) {
169
+ lines.push([span(C.gray, '')]);
170
+ lines.push([span(C.white, ' Set one with /key <api_key>, or press Enter on /key to paste it.')]);
171
+ lines.push([span(C.gray, ' Free keys: https://aegiscloud.org')]);
172
+ }
173
+ if (st.legacyPlaintext) {
174
+ lines.push([span(C.gray, '')]);
175
+ lines.push([
176
+ span(C.coral, ' ⚠ a plaintext copy of the key is still in config.json (another AEGIS CLI wrote it).'),
177
+ ]);
178
+ lines.push([span(C.gray, ' Re-save with /key <api_key> and delete that block when convenient.')]);
179
+ }
180
+ return lines;
181
+ }
182
+
183
+ /**
184
+ * Take a key from the argument or an echo-off prompt, save it, and report what
185
+ * the account said. Shared by `/key`, `/login` and `/cloud key` so all three
186
+ * behave identically — the whole point is that there is one way to set a key.
130
187
  */
131
- const cloudReady = () => !!process.env.AEGIS_API_KEY;
188
+ async function applyAccountKey(c, raw, { label = 'AEGIS API key' } = {}) {
189
+ let key = String(raw || '').trim();
190
+ if (!key) {
191
+ key = await c.readSecret(`${label} (kept off screen, Enter to cancel): `);
192
+ }
193
+ if (!key) {
194
+ note(c, 'no key given — /key <api_key> takes it inline, or press Enter on a bare /key to paste it');
195
+ return { ok: false };
196
+ }
197
+ const res = await c.setApiKey(key);
198
+ if (!res.ok) {
199
+ note(c, res.message || 'that key could not be saved');
200
+ return res;
201
+ }
202
+ if (res.error) {
203
+ note(c, `saved, but the account check failed: ${res.error.message}`);
204
+ note(c, 'the key is stored — /key status shows it; re-run /key <api_key> if it was mistyped');
205
+ return res;
206
+ }
207
+ const acct = (res.account && (res.account.email || res.account.plan)) || null;
208
+ done(c, `AEGIS key saved to ${res.path}${acct ? ` — signed in as ${acct}` : ' and verified'}`);
209
+ // The catalog is key-gated, so a freshly authenticated session should offer
210
+ // /model's picker without the user having to know to re-run it.
211
+ try {
212
+ await c.loadModels({ force: true });
213
+ const n = (c.state().models || []).length;
214
+ if (n) note(c, `${n} pinnable model${n === 1 ? '' : 's'} available — /model to pin one`);
215
+ } catch {}
216
+ return res;
217
+ }
218
+
219
+ /** Push + pull once, and report the counts (or the quota refusal). */
220
+ async function runCloudSync(c, o = {}) {
221
+ if (!c.client.apiKey) {
222
+ note(c, `no key — ${credentials.HOW_TO_SET}`);
223
+ return null;
224
+ }
225
+ const res = await c.withWorking(() => cloudsync.syncNow(c.client, o));
226
+ const pushed = (res.push && res.push.pushed) || [];
227
+ const failed = [...((res.push && res.push.failed) || []), ...((res.pull && res.pull.failed) || [])];
228
+ const pulled = (res.pull && res.pull.imported) || 0;
229
+ if (pushed.length) {
230
+ done(c, `pushed ${pushed.length} session${pushed.length === 1 ? '' : 's'} (${pushed[0].messages} messages in the first)`);
231
+ } else if (res.push && res.push.skipped && res.push.skipped.length) {
232
+ note(c, `${res.push.skipped.length} session(s) had nothing to send`);
233
+ } else {
234
+ note(c, 'nothing to push — every local session is already in the cloud');
235
+ }
236
+ if (pulled) done(c, `pulled ${pulled} record${pulled === 1 ? '' : 's'} into the local store — /resume lists them`);
237
+ else if (res.pull && res.pull.ok) note(c, `cloud has ${res.pull.remote || 0} session(s); nothing newer locally`);
238
+ for (const f of failed) {
239
+ note(c, `${f.kind === 'quota' ? 'quota' : 'failed'}: ${f.message}`);
240
+ if (f.hint) note(c, f.hint);
241
+ }
242
+ return res;
243
+ }
132
244
 
133
245
  /** Read + merge hook config from the standard settings files. */
134
246
  function readHooks() {
@@ -467,15 +579,32 @@ const COMMANDS = [
467
579
  },
468
580
  },
469
581
  {
470
- name: 'login', hint: '', category: 'auth',
471
- desc: 'Sign in to Claude Code',
472
- unavailable: 'sign-in uses Claude Code auth, which this client does not have — it authenticates with your AEGIS key',
473
- alt: '/byok-set',
582
+ name: 'login', aliases: ['signin'], args: ['value'], hint: '[<api_key>]', category: 'auth',
583
+ desc: 'Save your AEGIS API key',
584
+ handler: async (c, args) => {
585
+ // This used to be `unavailable` with `/byok-set` as its alternative —
586
+ // which was worse than useless: /byok-set stores a *provider* key
587
+ // server-side, so a user following that advice pasted their AEGIS key
588
+ // into a BYOK provider slot. Signing in to AEGIS Cloud *is* handing over
589
+ // an API key, and this is now where you give it one.
590
+ const arg = String(args.value || args._rest || '').trim();
591
+ await applyAccountKey(c, arg, { label: 'AEGIS API key' });
592
+ c.render();
593
+ return true;
594
+ },
474
595
  },
475
596
  {
476
- name: 'logout', hint: '', category: 'auth',
477
- desc: 'Sign out',
478
- unavailable: 'there is no Claude Code sign-in here to end — aegiscode holds no session token of its own',
597
+ name: 'logout', args: [], hint: '', category: 'auth',
598
+ desc: 'Remove the saved AEGIS API key',
599
+ handler: async (c) => {
600
+ const res = c.forgetApiKey();
601
+ done(c, res.cleared ? `key removed from ${res.path}` : 'there was no saved key to remove');
602
+ if (credentials.legacyKeyOnDisk()) {
603
+ note(c, `a copy is still in ${configPath()} (another AEGIS CLI wrote it) — delete its "aegiscloud" block too`);
604
+ }
605
+ c.render();
606
+ return true;
607
+ },
479
608
  },
480
609
  {
481
610
  name: 'model', aliases: ['m'], args: ['sub'], hint: '[id|-]', category: 'model',
@@ -709,8 +838,19 @@ const COMMANDS = [
709
838
  let git = false;
710
839
  try { execSync('git rev-parse --git-dir', { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'ignore'], timeout: 4000 }); git = true; } catch {}
711
840
  ok('git repo', git, git ? 'inside a work tree' : 'not a git repository');
712
- const key = process.env.AEGIS_API_KEY || '';
713
- ok('AEGIS_API_KEY', !!key, key ? 'set' : 'not set — export one (https://aegiscloud.org)');
841
+ const ks = credentials.keyStatus();
842
+ ok(
843
+ 'AEGIS key',
844
+ ks.configured,
845
+ ks.configured
846
+ ? `${maskKey(ks.key)} via ${credentials.sourceLabel(ks.source)}`
847
+ : `not set — ${credentials.HOW_TO_SET} (free at https://aegiscloud.org)`
848
+ );
849
+ ok('credentials', true, `${ks.path}${ks.fileMode ? ` (${ks.fileMode})` : ''}`);
850
+ ok('cloud memory', ks.memoryToken, ks.memoryToken ? 'token held' : 'no memory token — /cloud activate');
851
+ if (ks.legacyPlaintext) {
852
+ ok('plaintext key', false, `a copy also sits in ${configPath()} (another AEGIS CLI) — /key re-saves it to ${ks.path}`);
853
+ }
714
854
  ok('config path', true, configPath());
715
855
  const lines = [[span(C.gold + BOLD, 'Doctor'), span(BOLD_OFF, '')], [span(C.gray, '─'.repeat(30))]];
716
856
  for (const [good, label, detail] of checks) {
@@ -1205,17 +1345,145 @@ const COMMANDS = [
1205
1345
  },
1206
1346
  },
1207
1347
  {
1208
- name: 'cloud', args: ['sub', 'value'], hint: '[status|key <api_key>|activate|deactivate]', category: 'support',
1209
- desc: 'Show ÆGIS cloud sync status',
1348
+ name: 'cloud', args: ['sub', 'value'], hint: '[status|key <api_key>|activate|deactivate|sync [on|off|now]]', category: 'support',
1349
+ desc: 'Show and control ÆGIS cloud sync',
1210
1350
  handler: async (c, args) => {
1211
1351
  const sub = (args.sub || '').toLowerCase();
1212
- if (['key', 'activate', 'deactivate'].includes(sub)) {
1213
- note(c, 'ÆGIS cloud key/sync is managed by the aegis CLI: run `aegis login` (free) — aegiscode does not store cloud keys.');
1352
+ const rest = (args.value || args._rest || '').trim();
1353
+
1354
+ if (sub === 'key') {
1355
+ await applyAccountKey(c, rest);
1214
1356
  c.render();
1215
1357
  return true;
1216
1358
  }
1217
- const st = c.state();
1218
- panel(c, panels.buildAegisStatus({ ...st, cloud: { key: st.online, sync: st.online } }, c.ctx));
1359
+
1360
+ if (sub === 'activate') {
1361
+ if (!c.client.apiKey) {
1362
+ note(c, `no key — ${credentials.HOW_TO_SET}`);
1363
+ c.render();
1364
+ return true;
1365
+ }
1366
+ try {
1367
+ const res = await c.withWorking(() => c.client.memoryActivate());
1368
+ const token = await c.client.getMemoryToken();
1369
+ if (token) credentials.saveMemoryToken(token, { memorySubscribed: true });
1370
+ done(c, res && res.ok === false ? 'the server refused activation' : 'cloud memory activated for this account');
1371
+ if (res && res.token_limit) note(c, `quota: ${res.tokens_used || 0} / ${res.token_limit} synced tokens`);
1372
+ } catch (e) {
1373
+ note(c, `activation failed: ${e.message}`);
1374
+ }
1375
+ c.render();
1376
+ return true;
1377
+ }
1378
+
1379
+ if (sub === 'deactivate') {
1380
+ // There is no /api/memory/deactivate on the server, so this clears the
1381
+ // credential this host holds rather than pretending to unenrol the
1382
+ // account. Saying which one it did is the difference between a control
1383
+ // and a lie.
1384
+ credentials.clearMemoryToken();
1385
+ c.client.setApiKey(c.client.apiKey);
1386
+ note(c, 'cloud memory token cleared locally — sync stops until /cloud activate re-issues one');
1387
+ note(c, 'the account itself is still subscribed; manage the subscription at https://aegiscloud.org/subscribe');
1388
+ c.render();
1389
+ return true;
1390
+ }
1391
+
1392
+ if (sub === 'sync' || sub === 'push' || sub === 'pull') {
1393
+ const mode = sub === 'sync' ? (rest || 'status').toLowerCase() : sub;
1394
+ if (mode === 'on' || mode === 'off') {
1395
+ const on = mode === 'on';
1396
+ c.setCloudSync(on);
1397
+ note(c, on ? 'cloud sync on — /sync now pushes after each turn' : 'cloud sync off — nothing is sent until /sync now');
1398
+ c.render();
1399
+ return true;
1400
+ }
1401
+ if (mode === 'now') {
1402
+ await runCloudSync(c);
1403
+ c.render();
1404
+ return true;
1405
+ }
1406
+ const st = cloudsync.status();
1407
+ const rows = [
1408
+ [span(C.gold + BOLD, 'Cloud sync'), span(BOLD_OFF, '')],
1409
+ [span(C.gray, '─'.repeat(34))],
1410
+ [span(C.white, ` ${c.cloudSyncEnabled() ? 'on' : 'off'}`), span(C.gray, c.cloudSyncEnabled() ? ' (auto-push each turn)' : ' (manual: /sync now)')],
1411
+ [span(C.gray, ` local sessions: ${st.local} · pending push: ${st.pending} · in sync: ${st.synced}`)],
1412
+ [span(C.gray, ` last push: ${st.lastPushAt ? new Date(st.lastPushAt).toLocaleString() : 'never'} · last pull: ${st.lastPullAt ? new Date(st.lastPullAt).toLocaleString() : 'never'}`)],
1413
+ [span(C.gray, ` state: ${st.path}`)],
1414
+ ];
1415
+ for (const e of st.errors.slice(0, 3)) {
1416
+ rows.push([span(C.coral, ` ⚠ ${e.error}`)]);
1417
+ }
1418
+ panel(c, rows);
1419
+ c.render();
1420
+ return true;
1421
+ }
1422
+
1423
+ // Default: the whole picture — key plus sync state.
1424
+ const st = cloudsync.status();
1425
+ panel(c, [
1426
+ ...keyPanelLines(c, 'AEGIS cloud'),
1427
+ [span(C.gray, '')],
1428
+ [span(C.white + BOLD, ' Sync'), span(BOLD_OFF, '')],
1429
+ [span(C.gray, ` ${c.cloudSyncEnabled() ? 'on' : 'off'} · ${st.pending} pending · ${st.synced} in sync · ${st.importedRemote} pulled from cloud`)],
1430
+ [span(C.gray, ' /cloud sync on | now | off')],
1431
+ ]);
1432
+ c.render();
1433
+ return true;
1434
+ },
1435
+ },
1436
+ {
1437
+ name: 'key', aliases: ['apikey', 'api-key'], args: ['value'], hint: '[<api_key>|status|clear]', category: 'auth',
1438
+ desc: 'Save or show the AEGIS account key',
1439
+ handler: async (c, args) => {
1440
+ const arg = String(args.value || args._rest || '').trim();
1441
+ const lower = arg.toLowerCase();
1442
+ if (lower === 'status') {
1443
+ panel(c, keyPanelLines(c));
1444
+ c.render();
1445
+ return true;
1446
+ }
1447
+ if (lower === 'clear' || lower === 'remove' || lower === 'rm') {
1448
+ const res = c.forgetApiKey();
1449
+ done(c, res.cleared ? `key removed from ${res.path}` : 'there was no saved key to remove');
1450
+ if (credentials.legacyKeyOnDisk()) {
1451
+ note(c, `a copy is still in ${configPath()} (written by another AEGIS CLI) — delete the "aegiscloud" block to finish the job`);
1452
+ }
1453
+ c.render();
1454
+ return true;
1455
+ }
1456
+ await applyAccountKey(c, arg);
1457
+ c.render();
1458
+ return true;
1459
+ },
1460
+ },
1461
+ {
1462
+ name: 'sync', aliases: ['cloudsync', 'cloud-sync'], args: ['mode'], hint: '[now|on|off|status]', category: 'data',
1463
+ desc: 'Sync conversations with AEGIS Cloud',
1464
+ hidden: (c) => !cloudReady(),
1465
+ handler: async (c, args) => {
1466
+ const mode = (args.mode || '').toLowerCase();
1467
+ if (mode === 'on' || mode === 'off') {
1468
+ c.setCloudSync(mode === 'on');
1469
+ note(c, mode === 'on' ? 'cloud sync on' : 'cloud sync off — /sync now still runs a manual pass');
1470
+ c.render();
1471
+ return true;
1472
+ }
1473
+ if (mode === 'status') {
1474
+ const st = cloudsync.status();
1475
+ panel(c, [
1476
+ [span(C.gold + BOLD, 'Cloud sync'), span(BOLD_OFF, '')],
1477
+ [span(C.gray, '─'.repeat(34))],
1478
+ [span(C.white, ` ${c.cloudSyncEnabled() ? 'on' : 'off'}`)],
1479
+ [span(C.gray, ` local ${st.local} · pending ${st.pending} · in sync ${st.synced}`)],
1480
+ ]);
1481
+ c.render();
1482
+ return true;
1483
+ }
1484
+ // Bare /sync and /sync now both do the thing: this is the command a user
1485
+ // reaches for when they want their sessions in the cloud right now.
1486
+ await runCloudSync(c);
1219
1487
  c.render();
1220
1488
  return true;
1221
1489
  },
@@ -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
+ };