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.
- package/README.md +43 -8
- package/bin/aegiscode.js +161 -2
- package/package.json +2 -2
- package/src/app.js +193 -44
- package/src/chatflow.js +7 -4
- package/src/cloudsync.js +401 -0
- package/src/commands.js +304 -24
- package/src/config.js +6 -1
- package/src/credentials.js +323 -0
- package/src/history.js +34 -2
- package/src/overlays.js +18 -5
- package/src/panels.js +3 -1
- package/src/screens.js +159 -9
- package/src/secret.js +56 -0
- package/vendor/client/aegis.js +16 -1
- package/vendor/desktop/lib/local/engine.js +12 -7
|
@@ -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(
|
|
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: '
|
|
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
|
|
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, '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
402
|
-
*
|
|
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 =
|
|
411
|
-
|
|
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
|
};
|