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 CHANGED
@@ -38,12 +38,22 @@ npm install -g aegiscode
38
38
  node cli/bin/aegiscode.js
39
39
  ```
40
40
 
41
- Requires Node 18+. Set your key once:
41
+ Requires Node 18+. Save your key once — it goes to `~/.aegiscode/credentials.json`
42
+ (mode `0600`) and is then picked up by every later launch, script and shell:
42
43
 
43
44
  ```bash
44
- export AEGIS_API_KEY="aegis_..."
45
+ aegiscode login # prompts, no echo
46
+ aegiscode login "aegis_..." # or inline
47
+ aegiscode key status # masked key + which source is in use
48
+ aegiscode logout # remove it
45
49
  ```
46
50
 
51
+ `AEGIS_API_KEY` still works and still wins (useful in CI, or for a one-off);
52
+ `aegiscode --key <key>` applies to a single run and is never saved. A key an
53
+ older AEGIS CLI left in `~/.aegiscode/config.json` is picked up and copied into
54
+ the 0600 store automatically — `/cloud status` says so if the plaintext copy is
55
+ still there.
56
+
47
57
  ## Use
48
58
 
49
59
  ```bash
@@ -101,8 +111,8 @@ command for command — 75 entries across nine categories:
101
111
  | Session & context | `/clear` `/compact` `/cost` `/exit` `/new` `/recap` `/resume` `/rewind` `/agents` `/status` `/teleport` `/version` `/clone` `/schedule` |
102
112
  | Workspace | `/run` `/build` `/cd` `/copy` `/init` `/review` `/prs` |
103
113
  | Model & behavior | `/model` `/effort` `/thinking` `/theme` `/vim` `/router` `/confirm` `/yolo` `/permissions` `/hooks` `/skills` `/mcp` |
104
- | Data | `/context` `/export` `/tokens` |
105
- | Auth | `/credentials` `/byok` `/byok-set` `/byok-rm` |
114
+ | Data | `/context` `/export` `/tokens` `/sync` |
115
+ | Auth | `/key` `/login` `/logout` `/credentials` `/byok` `/byok-set` `/byok-rm` |
106
116
  | Support | `/help` `/doctor` `/troubleshooting` `/feedback` `/bug` `/issue` `/onboarding` `/benchmark` `/release-notes` `/billing` `/cloud` |
107
117
  | Aegis plugin | `/aegis-ask` `/aegis-status` `/aegis-recall` `/aegis-remember` `/memory` `/aegis-council` `/aegis-multi` `/aegis-print` `/aegis-import` `/tool` |
108
118
  | Fun | `/radio` `/waifu` |
@@ -119,10 +129,35 @@ the project and writes `AEGIS.md`, `/export` writes the transcript out,
119
129
  `/resume` and `/rewind` read the session store, `/cd` moves the working
120
130
  directory, `/doctor` runs diagnostics.
121
131
 
122
- The only two commands that answer "not available" are `/login` and `/logout`:
123
- their whole premise is Claude Code's own auth loop, which this client does not
124
- participate in it authenticates with an AEGIS key (or your own provider key
125
- via `/byok-set`). They say so, and point at the working alternative.
132
+ ## Account key and cloud sync
133
+
134
+ Three ways in, one store: `aegiscode login <key>`, `/key <api_key>` (or a bare
135
+ `/key` to paste it echo-off), and `$AEGIS_API_KEY`. The first two persist;
136
+ the env var outranks the store and needs no persistence. `/login` used to be an
137
+ unavailable Claude Code auth-loop command pointing at `/byok-set` — which stores
138
+ a *provider* key, so that advice sent the AEGIS key into the wrong slot. It is
139
+ now the in-band way to set the account key, and `/logout` removes it.
140
+
141
+ Conversation sync is the same `conversationSyncPush/Pull` surface the desktop
142
+ uses, over the sessions this host already keeps in `~/.aegiscode/history.jsonl`:
143
+
144
+ ```bash
145
+ /sync # push what is pending, then pull — the one you want
146
+ /sync status # local / pending / in-sync counts, last push and pull
147
+ /sync on | off # auto-push after each turn (off by default)
148
+ /cloud # key + sync state in one panel
149
+ /cloud activate # turn on cloud memory for the account
150
+ ```
151
+
152
+ Sync is **off by default** on purpose: the server charges a push for the growth
153
+ of a session against the plan's synced-token ceiling, so nothing is uploaded
154
+ until you ask. A refusal (HTTP 402) is reported as a quota refusal with the way
155
+ out, not as "sync failed". Imported remote sessions land in the same store
156
+ `/resume` reads, and are marked `imported` with estimated token counts — never
157
+ passed off as measured here.
158
+
159
+ There are currently **no** `unavailable` commands: every entry in the registry
160
+ either runs or is a real handler that says honestly what it cannot do.
126
161
 
127
162
  ## The chatflow
128
163
 
package/bin/aegiscode.js CHANGED
@@ -24,10 +24,16 @@ Usage:
24
24
  aegiscode -p "question" same, explicit
25
25
  echo "q" | aegiscode -p - read the prompt from stdin
26
26
 
27
+ Account:
28
+ aegiscode login [<key>] save your AEGIS API key (prompts, no echo, if omitted)
29
+ aegiscode logout remove the saved key
30
+ aegiscode key status show which key is in use and where it came from
31
+
27
32
  Options:
28
33
  -m, --model <id> pin a model id (see /models; default: server choice)
29
34
  --base <url> API base (default $AEGIS_API_BASE or aegiscloud.org)
30
- --key <key> API key for this run (prefer $AEGIS_API_KEY)
35
+ --key <key> API key for THIS RUN only — it is not saved. Use
36
+ "aegiscode login" to store one for good.
31
37
  --json with -p: emit JSON instead of text
32
38
  --no-stream buffer the answer instead of streaming it
33
39
  --max-tokens <n> output ceiling hint
@@ -59,9 +65,22 @@ function parseArgs(argv) {
59
65
  prompt: null,
60
66
  help: false,
61
67
  version: false,
68
+ command: null,
69
+ commandArg: null,
62
70
  };
63
71
  const rest = [];
64
72
 
73
+ // An account subcommand is `argv[0]` and nothing else — position 0 only, so
74
+ // `aegiscode -p "key"` (a one-shot prompt whose text happens to be one of
75
+ // these words) is never hijacked into an account operation.
76
+ const head = argv[0];
77
+ if (head && !head.startsWith('-') && ACCOUNT_COMMANDS.has(head)) {
78
+ const arg = argv[1];
79
+ opts.command = head;
80
+ opts.commandArg = arg && !arg.startsWith('-') ? arg : null;
81
+ argv = argv.slice(opts.commandArg ? 2 : 1);
82
+ }
83
+
65
84
  for (let i = 0; i < argv.length; i++) {
66
85
  const a = argv[i];
67
86
  const next = () => {
@@ -127,6 +146,134 @@ function parseArgs(argv) {
127
146
  return opts;
128
147
  }
129
148
 
149
+ /**
150
+ * Account subcommands: the in-band way to give this host a key.
151
+ *
152
+ * They exist because an `AEGIS_API_KEY` export was previously the *only* way
153
+ * in, and an export does not survive a new terminal. `--key` remains purely
154
+ * per-run (CI, a rotation test) so there is exactly one thing a user has to
155
+ * remember: `aegiscode login` saves it, `aegiscode logout` removes it.
156
+ */
157
+ const ACCOUNT_COMMANDS = new Set(['login', 'logout', 'key']);
158
+
159
+ /**
160
+ * Run `login` / `logout` / `key` and return a process exit code.
161
+ *
162
+ * Exit codes are meaningful because these are the commands a script calls:
163
+ * 0 = done, 1 = the server refused the credential, 2 = no credential and no
164
+ * way to ask for one (no TTY).
165
+ */
166
+ async function runAccountCommand(command, arg, io = {}) {
167
+ const stdout = io.stdout || process.stdout;
168
+ const stderr = io.stderr || process.stderr;
169
+ const stdin = io.stdin || process.stdin;
170
+ const { credentials, readSecret, maskKey } = loadAccountDeps();
171
+
172
+ const where = () => credentials.credentialsPath();
173
+
174
+ if (command === 'logout' || (command === 'key' && ['clear', 'remove', 'rm'].includes(String(arg || '').toLowerCase()))) {
175
+ const res = credentials.clearApiKey();
176
+ stdout.write(
177
+ res.cleared
178
+ ? `aegiscode: key removed from ${res.path}\n`
179
+ : 'aegiscode: no saved key to remove\n'
180
+ );
181
+ if (credentials.legacyKeyOnDisk()) {
182
+ stdout.write(
183
+ 'aegiscode: note — a plaintext copy is also in config.json, written by an older ' +
184
+ 'AEGIS CLI; delete its "aegiscloud" block as well to finish removing it\n'
185
+ );
186
+ }
187
+ return 0;
188
+ }
189
+
190
+ if (command === 'key' && ['status', 'show', ''].includes(String(arg || '').toLowerCase())) {
191
+ const st = credentials.keyStatus();
192
+ if (io.json) {
193
+ stdout.write(
194
+ JSON.stringify(
195
+ {
196
+ configured: st.configured,
197
+ source: st.source,
198
+ source_label: credentials.sourceLabel(st.source),
199
+ path: st.path,
200
+ file_mode: st.fileMode,
201
+ masked: st.configured ? maskKey(st.key) : null,
202
+ memory_token: st.memoryToken,
203
+ verified_at: st.verifiedAt,
204
+ plaintext_copy_in_config: st.legacyPlaintext,
205
+ },
206
+ null,
207
+ 2
208
+ ) + '\n'
209
+ );
210
+ return st.configured ? 0 : 1;
211
+ }
212
+ stdout.write(
213
+ [
214
+ `key: ${st.configured ? maskKey(st.key) : 'not set'}`,
215
+ `source: ${credentials.sourceLabel(st.source)}`,
216
+ `file: ${st.path}${st.fileMode ? ` (${st.fileMode})` : ''}`,
217
+ `memory: ${st.memoryToken ? 'token held (cloud sync ready)' : 'no token'}`,
218
+ st.verifiedAt ? `verified: ${st.verifiedAt}` : null,
219
+ st.legacyPlaintext
220
+ ? `note: a plaintext copy also sits in config.json — re-save with \`aegiscode login\` to move it`
221
+ : null,
222
+ ]
223
+ .filter(Boolean)
224
+ .join('\n') + '\n'
225
+ );
226
+ return st.configured ? 0 : 1;
227
+ }
228
+
229
+ // `login [<key>]` and `key <api_key>`: take the key inline or ask for it.
230
+ let key = arg && arg !== 'status' ? String(arg).trim() : '';
231
+ if (!key) {
232
+ if (!stdin.isTTY) {
233
+ stderr.write(
234
+ 'aegiscode: no terminal to prompt on — pass the key: `aegiscode login <api_key>`\n' +
235
+ ' (or set AEGIS_API_KEY for a single run)\n'
236
+ );
237
+ return 2;
238
+ }
239
+ key = await readSecret('AEGIS API key (kept off screen, Enter to cancel): ', { stdin, stdout });
240
+ }
241
+ if (!key) {
242
+ stderr.write('aegiscode: no key given — nothing saved\n');
243
+ return 2;
244
+ }
245
+
246
+ const { createApp } = require('../src/app.js');
247
+ const app = createApp({ interactive: false });
248
+ const res = await app.setApiKey(key);
249
+ if (!res.ok) {
250
+ stderr.write(`aegiscode: ${res.message || 'that key could not be saved'}\n`);
251
+ return 2;
252
+ }
253
+ const who = res.account && (res.account.email || res.account.plan);
254
+ stdout.write(`aegiscode: key saved to ${res.path}${who ? ` — ${who}` : ''}\n`);
255
+ if (res.error) {
256
+ const status = res.error.status;
257
+ stderr.write(`aegiscode: the account check failed: ${res.error.message}\n`);
258
+ if (status === 401 || status === 403) {
259
+ stderr.write('aegiscode: the key is stored but the server refused it — re-run with a fresh key\n');
260
+ return 1;
261
+ }
262
+ stderr.write('aegiscode: (stored anyway — the server could not be reached to confirm it)\n');
263
+ return 0;
264
+ }
265
+ stdout.write('aegiscode: verified — run `aegiscode` to start a session\n');
266
+ return 0;
267
+ }
268
+
269
+ /** Lazily required so `--version`/`--help` stay dependency-free and instant. */
270
+ function loadAccountDeps() {
271
+ const credentials = require('../src/credentials.js');
272
+ const { readSecret } = require('../src/secret.js');
273
+ const { maskKey } = require('../src/format.js');
274
+ return { credentials, readSecret, maskKey };
275
+ }
276
+
130
277
  function readStdin() {
131
278
  return new Promise((resolve) => {
132
279
  let buf = '';
@@ -163,6 +310,18 @@ async function main(argv = process.argv.slice(2)) {
163
310
  if (opts.base) process.env.AEGIS_API_BASE = opts.base;
164
311
  if (opts.key) process.env.AEGIS_API_KEY = opts.key;
165
312
 
313
+ // Account subcommands run before a session is built: they are one-shot,
314
+ // non-interactive, and must work in a script (`aegiscode login "$KEY"`) as
315
+ // well as at a prompt.
316
+ if (ACCOUNT_COMMANDS.has(opts.command)) {
317
+ return runAccountCommand(opts.command, opts.commandArg, {
318
+ json: opts.json,
319
+ stdin: process.stdin,
320
+ stdout: process.stdout,
321
+ stderr: process.stderr,
322
+ });
323
+ }
324
+
166
325
  const { createApp } = require('../src/app.js');
167
326
 
168
327
  let prompt = opts.prompt;
@@ -214,4 +373,4 @@ if (require.main === module) {
214
373
  });
215
374
  }
216
375
 
217
- module.exports = { main, parseArgs, HELP };
376
+ module.exports = { main, parseArgs, HELP, ACCOUNT_COMMANDS, runAccountCommand };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "aegiscode",
3
3
  "productName": "AEGIS Code",
4
- "version": "6.3.1",
5
- "description": "aegiscode the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
4
+ "version": "6.4.0",
5
+ "description": "aegiscode \u2014 the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
8
8
  "email": "nborneklint@gmail.com"
package/src/app.js CHANGED
@@ -27,6 +27,9 @@ const { GLYPH, VERBS, themeOf, RESET, THEME_TABLE } = require('./theme.js');
27
27
  const { LiveRegion, termWidth, w } = require('./screen.js');
28
28
  const { parseLine, COMMANDS, visibleCommands } = require('./commands.js');
29
29
  const { updateConfig, loadPermissions, loadConfig, configExists } = require('./config.js');
30
+ const credentials = require('./credentials.js');
31
+ const cloudsync = require('./cloudsync.js');
32
+ const { readSecret } = require('./secret.js');
30
33
  const { normalizeModelCatalog, pickerEntries, catalogIds } = require('./models.js');
31
34
  const { appendHistory, readSessionTranscript, readOwnSessions } = require('./history.js');
32
35
  const { snapshotCheckpoint } = require('./checkpoint.js');
@@ -50,7 +53,12 @@ function createApp(options = {}) {
50
53
  };
51
54
  const out = options.out || process.stdout;
52
55
  const err = options.err || process.stderr;
53
- const client = options.client || createClient();
56
+ // The key comes from the credential store (env → credentials.json →
57
+ // config.json) rather than from the environment alone: an export does not
58
+ // survive a new shell, and until it is *stored* every one of those launches
59
+ // is "no key" with no in-band way to set one. An injected client keeps
60
+ // whatever key it was built with.
61
+ const client = options.client || createClient(credentials.clientOptions());
54
62
  const { TOOLS, toolList } = options.tools || createTools(client);
55
63
 
56
64
  // Tool-approval gate (exec/writeFile/editFile confirm before running).
@@ -87,7 +95,11 @@ function createApp(options = {}) {
87
95
  const commandCtx = {
88
96
  light: opts.light,
89
97
  model: opts.model,
90
- effort: 'high',
98
+ // `null` = "auto": send no effort, so the server sizes the turn from the
99
+ // ask. This is the *budget* control on the pooled class (aegis1 sizes the
100
+ // token ladder from it), which is why it is not defaulted to a rung here —
101
+ // pinning one would make every turn cost what that rung grants.
102
+ effort: null,
91
103
  thinking: false,
92
104
  themeIndex: opts.light ? 2 : 1,
93
105
  vim: false,
@@ -147,6 +159,83 @@ function createApp(options = {}) {
147
159
  }
148
160
  }
149
161
 
162
+ // ── the AEGIS account key ─────────────────────────────────────────────────
163
+ //
164
+ // Set/forget at runtime, for `/key` and `aegiscode login`. Both paths are the
165
+ // same three steps — persist, apply to the live client, invalidate the model
166
+ // catalog — because a key that is stored but not applied leaves the current
167
+ // session failing, and one that is applied but not stored is gone next launch.
168
+
169
+ /**
170
+ * Persist an account key and start using it immediately.
171
+ *
172
+ * `verify` spends one `/api/verify-api-key` round trip to catch a bad key at
173
+ * the moment it is entered rather than at the first prompt, and is also how
174
+ * the memory token (cloud sync's credential) is obtained — so a successful
175
+ * login leaves both credentials in the store.
176
+ */
177
+ async function setApiKey(raw, { verify = true } = {}) {
178
+ const saved = credentials.saveApiKey(raw);
179
+ if (!saved.ok) return saved;
180
+ client.setApiKey(saved.key);
181
+ modelCache.models = [];
182
+ modelCache.at = 0;
183
+ let account = null;
184
+ let error = null;
185
+ if (verify) {
186
+ try {
187
+ const info = (await client.verifyApiKey()) || {};
188
+ account = info;
189
+ const patch = { verifiedAt: new Date().toISOString() };
190
+ if (info.memory_token) patch.memoryToken = info.memory_token;
191
+ if (info.plan) patch.plan = info.plan;
192
+ patch.account = { plan: info.plan || null, email: info.email || null, valid: info.valid !== false };
193
+ credentials.writeCredentials(patch);
194
+ if (info.plan) session.plan = info.plan;
195
+ if (info.email) session.account = { ...(session.account || {}), email: info.email };
196
+ } catch (e) {
197
+ error = e;
198
+ }
199
+ }
200
+ return { ...saved, account, error };
201
+ }
202
+
203
+ /** Drop the stored key and stop authenticating this session. */
204
+ function forgetApiKey() {
205
+ const res = credentials.clearApiKey();
206
+ client.setApiKey('');
207
+ modelCache.models = [];
208
+ modelCache.at = 0;
209
+ return res;
210
+ }
211
+
212
+ function cloudSyncEnabled() {
213
+ try {
214
+ return loadConfig().cloudSync === true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ function setCloudSync(on) {
221
+ return updateConfig({ cloudSync: on === true });
222
+ }
223
+
224
+ /**
225
+ * Copy a key found in config.json into the 0600 store, once, so the next run
226
+ * reads the tight copy. Never deletes the original — that file belongs to
227
+ * another product and holds the user's key.
228
+ */
229
+ function adoptLegacyKeyOnce() {
230
+ try {
231
+ const resolved = credentials.resolveApiKey();
232
+ if (resolved.source !== 'config') return [];
233
+ return credentials.adoptLegacy().adopted;
234
+ } catch {
235
+ return [];
236
+ }
237
+ }
238
+
150
239
  // ── the AEGIS Cloud model catalog ──────────────────────────────────────────
151
240
  //
152
241
  // `/model` (and the alt+p chord that dispatches it) reads the pinnable ids
@@ -371,6 +460,14 @@ function createApp(options = {}) {
371
460
  model: commandCtx.model || undefined,
372
461
  system: opts.system,
373
462
  maxTokens: opts.maxTokens,
463
+ // `effort` is the pooled budget control, and it travels on every turn
464
+ // — this host only ever runs the 'aegis' class, whose calls are sized
465
+ // server-side from it (aegis1 services/pool_brain.py). It used to be
466
+ // held in the session state and rendered in the status line without
467
+ // ever reaching the wire, so /effort changed the display and nothing
468
+ // about what the turn cost. `null` (auto) is omitted so the server
469
+ // infers the rung from the ask.
470
+ effort: commandCtx.effort || undefined,
374
471
  // false asks the engine for the buffered (non-stream) wire form, so
375
472
  // `--no-stream` and piped runs get a single body rather than SSE.
376
473
  stream: commandCtx.stream !== false,
@@ -436,7 +533,7 @@ function createApp(options = {}) {
436
533
  render.renderNotice(
437
534
  ctx(),
438
535
  'error',
439
- 'no AEGIS_API_KEY setexport one (https://aegiscloud.org) or run /byok-set'
536
+ `no AEGIS account key ${credentials.HOW_TO_SET} (or set $${credentials.KEY_ENV})`
440
537
  )
441
538
  );
442
539
  return;
@@ -494,7 +591,7 @@ function createApp(options = {}) {
494
591
  const tool = TOOLS[name];
495
592
  if (!tool) throw new Error(`unknown tool: ${name}`);
496
593
  if (!client.apiKey) {
497
- throw new Error('no AEGIS_API_KEY setexport one (https://aegiscloud.org)');
594
+ throw new Error(`no AEGIS account key ${credentials.HOW_TO_SET}`);
498
595
  }
499
596
  const text = await tool.run(args || {});
500
597
  emit(render.renderToolResult(ctx(), name, text, width()));
@@ -713,6 +810,17 @@ function createApp(options = {}) {
713
810
  TOOLS,
714
811
  saveConfig: (patch) => updateConfig(patch),
715
812
  showThemePicker: () => showThemePicker(),
813
+ // Credential + cloud-sync surface for the /key, /cloud and /sync commands.
814
+ // Defined here on the app's context so the chatflow's Object.assign-based
815
+ // context inherits them too — one definition, both hosts.
816
+ keyStatus: () => credentials.keyStatus(),
817
+ setApiKey: (raw, o) => setApiKey(raw, o),
818
+ forgetApiKey: () => forgetApiKey(),
819
+ readSecret: (text) => readSecret(text, { stdin: process.stdin, stdout: out }),
820
+ cloudsync,
821
+ cloudSyncEnabled: () => cloudSyncEnabled(),
822
+ setCloudSync: (on) => setCloudSync(on),
823
+ adoptLegacyKey: () => adoptLegacyKeyOnce(),
716
824
  };
717
825
  Object.defineProperty(c, 'sessionId', {
718
826
  enumerable: true,
@@ -801,7 +909,7 @@ function createApp(options = {}) {
801
909
  try {
802
910
  let args = cmd.build(arg);
803
911
  if (cmd.secret) {
804
- const key = await readSecret(`provider key for ${args.provider}: `);
912
+ const key = await askSecret(`provider key for ${args.provider}: `);
805
913
  if (!key) {
806
914
  emit(render.renderNotice(ctx(), 'warn', 'empty key — nothing sent'));
807
915
  return true;
@@ -816,52 +924,26 @@ function createApp(options = {}) {
816
924
  return true;
817
925
  }
818
926
 
819
- /** Read a secret without echoing it (raw mode; falls back to plain input). */
820
- function readSecret(promptText) {
821
- if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
822
- return Promise.resolve('');
823
- }
824
- return new Promise((resolve) => {
825
- const t = themeOf(ctx());
826
- out.write(t.gray + promptText + RESET);
827
- const stdin = process.stdin;
828
- let buf = '';
829
- stdin.setRawMode(true);
830
- stdin.resume();
831
- const onData = (chunk) => {
832
- for (const ch of chunk.toString('utf8')) {
833
- if (ch === '\r' || ch === '\n') {
834
- stdin.setRawMode(false);
835
- stdin.removeListener('data', onData);
836
- out.write('\n');
837
- return resolve(buf);
838
- }
839
- if (ch === '\x03') {
840
- // ctrl+c
841
- stdin.setRawMode(false);
842
- stdin.removeListener('data', onData);
843
- out.write('\n');
844
- return resolve('');
845
- }
846
- if (ch === '\u007f') {
847
- buf = buf.slice(0, -1);
848
- out.write('\b \b');
849
- continue;
850
- }
851
- buf += ch;
852
- out.write('•');
853
- }
854
- };
855
- stdin.on('data', onData);
856
- });
927
+ /**
928
+ * Ask for a secret on this session's streams.
929
+ *
930
+ * The prompt loop itself lives in secret.js, shared with `aegiscode login` in
931
+ * the bin — one implementation, so the terminal host and the non-interactive
932
+ * entry point cannot differ in whether a key is echoed or masked. Only the
933
+ * prompt's colouring is the session's business.
934
+ */
935
+ function askSecret(promptText) {
936
+ const t = themeOf(ctx());
937
+ return readSecret(t.gray + promptText + RESET, { stdin: process.stdin, stdout: out });
857
938
  }
858
939
 
859
940
  // --- entry points ---------------------------------------------------------
860
941
 
861
942
  /** Non-interactive: one prompt, plain output, exit code. */
862
943
  async function runOnce(prompt, { json = false } = {}) {
944
+ adoptLegacyKeyOnce();
863
945
  if (!client.apiKey) {
864
- err.write('aegiscode: no AEGIS_API_KEY set. Export your key first (https://aegiscloud.org).\n');
946
+ err.write(`aegiscode: no AEGIS account key. ${credentials.HOW_TO_SET}, or set $${credentials.KEY_ENV}.\n`);
865
947
  return 2;
866
948
  }
867
949
  const res = await ask(prompt);
@@ -946,6 +1028,39 @@ function createApp(options = {}) {
946
1028
  } catch {
947
1029
  /* persistence is best-effort */
948
1030
  }
1031
+ // Auto-sync hook: one place, reached by both the linear REPL and the
1032
+ // chatflow's session loop, and only when the user turned it on. Off by
1033
+ // default because a push is billed against the plan's synced-token ceiling
1034
+ // (aegis1 charges the *growth* of a session), so uploading is a choice.
1035
+ if (cloudSyncEnabled()) autoSync();
1036
+ }
1037
+
1038
+ /**
1039
+ * Push after a turn, in the background.
1040
+ *
1041
+ * Deliberately not awaited and deliberately quiet on success: this runs after
1042
+ * every turn, and a line per turn would be noise. Failures are reported once
1043
+ * per session per session-cycle — a quota refusal that repeats forever is a
1044
+ * wall of text, not information — and never thrown, because the user's turn
1045
+ * already succeeded by the time this runs.
1046
+ */
1047
+ let autoSyncNotes = new Set();
1048
+ function autoSync() {
1049
+ if (!client.apiKey) return;
1050
+ Promise.resolve()
1051
+ .then(() => cloudsync.push(client))
1052
+ .then((res) => {
1053
+ if (res && res.failed && res.failed.length) {
1054
+ const f = res.failed[0];
1055
+ if (autoSyncNotes.has(f.kind)) return;
1056
+ autoSyncNotes.add(f.kind);
1057
+ const hint = f.hint ? ` — ${f.hint}` : '';
1058
+ emit(render.renderNotice(ctx(), f.kind === 'quota' ? 'warn' : 'error', `cloud sync: ${f.message}${hint}`));
1059
+ }
1060
+ })
1061
+ .catch(() => {
1062
+ /* offline is not worth interrupting a session over */
1063
+ });
949
1064
  }
950
1065
 
951
1066
  /** One line of session accounting, for ctrl+t and the meta row. */ function tokenSummary() {
@@ -1073,6 +1188,8 @@ function createApp(options = {}) {
1073
1188
  return;
1074
1189
  }
1075
1190
  if (!opts.model && cfg.model) commandCtx.model = cfg.model;
1191
+ // `null` in the config is "auto" and is deliberately not assigned over the
1192
+ // default — there is nothing to restore, because nothing is pinned.
1076
1193
  if (cfg.effort) commandCtx.effort = cfg.effort;
1077
1194
  if (typeof cfg.vim === 'boolean') commandCtx.vim = cfg.vim;
1078
1195
  if (cfg.lastRecap) commandCtx.lastRecap = cfg.lastRecap;
@@ -1101,6 +1218,7 @@ function createApp(options = {}) {
1101
1218
  process.stdout.isTTY;
1102
1219
  if (tty) {
1103
1220
  restorePrefs();
1221
+ adoptLegacyKeyOnce();
1104
1222
  // Onboarding runs in the normal buffer, before the session takes the
1105
1223
  // alternate screen — the reference's order. A declined trust check must
1106
1224
  // abort: the reference returns without ever reaching `session(ctx)`.
@@ -1108,8 +1226,31 @@ function createApp(options = {}) {
1108
1226
  continue: !!options.continue,
1109
1227
  seen: options.seen || configExists,
1110
1228
  save: (patch) => updateConfig(patch),
1229
+ // With no key the session can do nothing, so ask for it here where the
1230
+ // user is already answering questions — verified on submit, and saved
1231
+ // 0600 so the next launch (and every script) has it.
1232
+ needsKey: () => !client.apiKey,
1233
+ submitKey: (key) => setApiKey(key),
1111
1234
  });
1112
1235
  if (!onboard.ok) return 0;
1236
+ if (onboard.key && onboard.key.set) {
1237
+ emit(
1238
+ render.renderNotice(
1239
+ ctx(),
1240
+ 'info',
1241
+ `key saved to ${credentials.credentialsPath()} — no export needed from now on`
1242
+ )
1243
+ );
1244
+ }
1245
+ if (onboard.key && onboard.key.skipped && !client.apiKey) {
1246
+ emit(
1247
+ render.renderNotice(
1248
+ ctx(),
1249
+ 'warn',
1250
+ `no key saved — /key <api_key> or \`aegiscode login\` whenever you are ready`
1251
+ )
1252
+ );
1253
+ }
1113
1254
  // A stored pin the platform does not advertise routes elsewhere in
1114
1255
  // silence (see validatePinnedModel) — checked once, here, where the user
1115
1256
  // can act on it. Not on `-p`: a network round-trip ahead of the first
@@ -1129,6 +1270,7 @@ function createApp(options = {}) {
1129
1270
  return 0;
1130
1271
  }
1131
1272
  restorePrefs();
1273
+ adoptLegacyKeyOnce();
1132
1274
  return runLinearRepl();
1133
1275
  }
1134
1276
 
@@ -1154,6 +1296,13 @@ function createApp(options = {}) {
1154
1296
  recordTurn,
1155
1297
  persistTurn,
1156
1298
  restorePrefs,
1299
+ setApiKey,
1300
+ forgetApiKey,
1301
+ adoptLegacyKeyOnce,
1302
+ cloudSyncEnabled,
1303
+ setCloudSync,
1304
+ cloudsync,
1305
+ keyStatus: () => credentials.keyStatus(),
1157
1306
  sessionId: () => commandCtx.sessionId,
1158
1307
  tokenSummary,
1159
1308
  resumeSession,
package/src/chatflow.js CHANGED
@@ -164,7 +164,7 @@ const separatorLine = (cols, ctx) => [span(themeOf(ctx).dim, '─'.repeat(cols))
164
164
  /** The idle bottom-left line: which effort level the next turn runs at. */
165
165
  function effortLine(ctx, cols) {
166
166
  const t = themeOf(ctx);
167
- const txt = `● ${ctx.effort || 'high'} · /effort`;
167
+ const txt = `● ${ctx.effort || 'auto'} · /effort`;
168
168
  const pad = ' '.repeat(Math.max(0, cols - [...txt].length - 4));
169
169
  return [span(t.gray, pad + txt)];
170
170
  }
@@ -1111,12 +1111,15 @@ async function runSession(host) {
1111
1111
  return;
1112
1112
  }
1113
1113
  if (type === 'effort') {
1114
- const levels = ['low', 'medium', 'high'];
1115
- const chosen = levels[overlay.sel || 0];
1114
+ // The picker's own order, not a second copy of the level names: this
1115
+ // used to re-declare ['low','medium','high'] here while the picker drew
1116
+ // its own table, so the row a user picked and the value it stored were
1117
+ // resolved through different lists.
1118
+ const chosen = overlays.EFFORT_VALUES[overlay.sel || 0];
1116
1119
  overlay = null;
1117
1120
  ctx.effort = chosen;
1118
1121
  host.updateConfig({ effort: chosen });
1119
- note(`effort: ${chosen}`);
1122
+ note(`effort: ${chosen || 'auto'}`);
1120
1123
  render();
1121
1124
  return;
1122
1125
  }