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/README.md +43 -8
- package/bin/aegiscode.js +161 -2
- package/package.json +2 -2
- package/src/app.js +178 -43
- package/src/cloudsync.js +401 -0
- package/src/commands.js +284 -16
- package/src/credentials.js +323 -0
- package/src/history.js +34 -2
- package/src/screens.js +159 -9
- package/src/secret.js +56 -0
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/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
|
};
|
package/src/secret.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a secret from the terminal without echoing it.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from app.js so the non-interactive entry point can use the same
|
|
7
|
+
* prompt as the in-session commands: `aegiscode login` asks for the account key
|
|
8
|
+
* on the same terms `/key` does, and a second copy of this loop would be a
|
|
9
|
+
* second place for a key to end up echoed to the screen or in a shell history.
|
|
10
|
+
*
|
|
11
|
+
* Raw mode when the stream supports it; a stream that cannot mask (no TTY, no
|
|
12
|
+
* setRawMode) resolves to '' rather than silently reading an echoed secret —
|
|
13
|
+
* every caller treats '' as "nothing given" and says so.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const MASK = '\u2022'; // •
|
|
17
|
+
|
|
18
|
+
function readSecret(promptText, o = {}) {
|
|
19
|
+
const stdin = o.stdin || process.stdin;
|
|
20
|
+
const stdout = o.stdout || process.stdout;
|
|
21
|
+
const canMask = !!stdin.isTTY && typeof stdin.setRawMode === 'function';
|
|
22
|
+
if (!canMask) return Promise.resolve('');
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
stdout.write(promptText);
|
|
25
|
+
let buf = '';
|
|
26
|
+
const done = (value) => {
|
|
27
|
+
try {
|
|
28
|
+
stdin.setRawMode(false);
|
|
29
|
+
} catch {}
|
|
30
|
+
stdin.removeListener('data', onData);
|
|
31
|
+
stdout.write('\n');
|
|
32
|
+
resolve(value);
|
|
33
|
+
};
|
|
34
|
+
const onData = (chunk) => {
|
|
35
|
+
// Drop terminal escape sequences whole: skipping only the ESC leaves the
|
|
36
|
+
// CSI tail ([A, [3~, …) to be appended to the key.
|
|
37
|
+
const text = String(chunk).replace(/\x1b\[[0-9;?]*[A-Za-z~]/g, '').replace(/\x1b./g, '');
|
|
38
|
+
for (const ch of text) {
|
|
39
|
+
if (ch === '\r' || ch === '\n') return done(buf);
|
|
40
|
+
if (ch === '\x03' || ch === '\x04') return done(''); // ctrl+c / ctrl+d
|
|
41
|
+
if (ch === '\u007f' || ch === '\b') {
|
|
42
|
+
buf = buf.slice(0, -1);
|
|
43
|
+
stdout.write('\b \b');
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
buf += ch;
|
|
47
|
+
stdout.write(MASK);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
stdin.setRawMode(true);
|
|
51
|
+
stdin.resume();
|
|
52
|
+
stdin.on('data', onData);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { readSecret, MASK };
|