aegiscode 6.3.2 → 6.5.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,26 @@ 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
+
57
+ That one file is shared: the MCP plugin and AEGIS Desktop read the same store, so
58
+ signing in here signs you in everywhere (see [One memory, shared with AEGIS
59
+ Desktop](#one-memory-shared-with-aegis-desktop)).
60
+
47
61
  ## Use
48
62
 
49
63
  ```bash
@@ -101,8 +115,8 @@ command for command — 75 entries across nine categories:
101
115
  | Session & context | `/clear` `/compact` `/cost` `/exit` `/new` `/recap` `/resume` `/rewind` `/agents` `/status` `/teleport` `/version` `/clone` `/schedule` |
102
116
  | Workspace | `/run` `/build` `/cd` `/copy` `/init` `/review` `/prs` |
103
117
  | 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` |
118
+ | Data | `/context` `/export` `/tokens` `/sync` |
119
+ | Auth | `/key` `/login` `/logout` `/credentials` `/byok` `/byok-set` `/byok-rm` |
106
120
  | Support | `/help` `/doctor` `/troubleshooting` `/feedback` `/bug` `/issue` `/onboarding` `/benchmark` `/release-notes` `/billing` `/cloud` |
107
121
  | Aegis plugin | `/aegis-ask` `/aegis-status` `/aegis-recall` `/aegis-remember` `/memory` `/aegis-council` `/aegis-multi` `/aegis-print` `/aegis-import` `/tool` |
108
122
  | Fun | `/radio` `/waifu` |
@@ -119,10 +133,60 @@ the project and writes `AEGIS.md`, `/export` writes the transcript out,
119
133
  `/resume` and `/rewind` read the session store, `/cd` moves the working
120
134
  directory, `/doctor` runs diagnostics.
121
135
 
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.
136
+ ## Account key and cloud sync
137
+
138
+ Three ways in, one store: `aegiscode login <key>`, `/key <api_key>` (or a bare
139
+ `/key` to paste it echo-off), and `$AEGIS_API_KEY`. The first two persist;
140
+ the env var outranks the store and needs no persistence. `/login` used to be an
141
+ unavailable Claude Code auth-loop command pointing at `/byok-set` — which stores
142
+ a *provider* key, so that advice sent the AEGIS key into the wrong slot. It is
143
+ now the in-band way to set the account key, and `/logout` removes it.
144
+
145
+ Conversation sync is the same `conversationSyncPush/Pull` surface the desktop
146
+ uses, over the sessions this host keeps in `~/.aegiscode/history.jsonl`:
147
+
148
+ ```bash
149
+ /sync # push what is pending, then pull — the one you want
150
+ /sync status # local / pending / in-sync counts, last push and pull
151
+ /sync on | off # auto-push after each turn (off by default)
152
+ /cloud # key + sync state in one panel
153
+ /cloud activate # turn on cloud memory for the account
154
+ ```
155
+
156
+ Sync is **off by default** on purpose: the server charges a push for the growth
157
+ of a session against the plan's synced-token ceiling, so nothing is uploaded
158
+ until you ask. A refusal (HTTP 402) is reported as a quota refusal with the way
159
+ out, not as "sync failed". Imported remote sessions land in the same store
160
+ `/resume` reads, and are marked `imported` with estimated token counts — never
161
+ passed off as measured here.
162
+
163
+ ## One memory, shared with AEGIS Desktop
164
+
165
+ `~/.aegiscode/` is the data dir for **every** AEGIS host — this CLI, AEGIS
166
+ Desktop and the MCP plugin built on the same account:
167
+
168
+ | File | What it holds | Written by |
169
+ |---|---|---|
170
+ | `credentials.json` | the account key, mode 0600 | `aegiscode login`, `/key`, the desktop's Settings pane |
171
+ | `sessions.json` | every conversation, one record per session | both hosts, live |
172
+ | `history.jsonl` | this host's per-exchange ledger (feeds `/cost`) | the CLI |
173
+ | `config.json` | preferences, permissions | the CLI |
174
+
175
+ Sign in once and all three hosts are signed in: they resolve the key with the
176
+ same precedence (`$AEGIS_API_KEY` → `credentials.json` → an older `config.json`),
177
+ and when two hosts hold a key the **most recently saved one wins** — so rotating
178
+ it from the terminal does not leave the app 401-ing on a stale string.
179
+
180
+ Sessions are shared too, and not only through the cloud. `/resume` lists a
181
+ thread typed in the desktop, and the desktop's session list shows a thread typed
182
+ here — same file, no sync and no network. An upgrade adopts a desktop install's
183
+ private `sessions.json` into the shared store once, so no existing conversation
184
+ is lost. Records carry `origin`, and terminal sessions are **not** enrolled in
185
+ the desktop's push queue (that would spend the account's synced-token quota as a
186
+ side effect of typing in a shell); this host's own `/sync` covers them.
187
+
188
+ There are currently **no** `unavailable` commands: every entry in the registry
189
+ either runs or is a real handler that says honestly what it cannot do.
126
190
 
127
191
  ## The chatflow
128
192
 
@@ -181,9 +245,11 @@ What the loop does, in the order a turn happens:
181
245
  `/resume` the session list, `?` the shortcut grid, `ctrl+o` permissions, and a
182
246
  centred Yes/No dialog when a mutating tool needs approval. `Esc` closes an
183
247
  overlay, and clears the input line when nothing is open.
184
- - **Every turn is persisted** to `~/.aegiscode/history.jsonl`, with a transcript
185
- checkpoint alongside it, so `/resume`, `/cost`, `/clear` and `/rewind` all have
186
- something real to read. On exit the session prints how to come back to it.
248
+ - **Every turn is persisted** to `~/.aegiscode/history.jsonl` and mirrored into
249
+ the shared `~/.aegiscode/sessions.json`, with a transcript checkpoint alongside
250
+ them, so `/resume`, `/cost`, `/clear` and `/rewind` all have something real to
251
+ read — and the desktop app sees the same conversations. On exit the session
252
+ prints how to come back to it.
187
253
 
188
254
  Anything that is not a real terminal — a pipe, `-p`, a CI run — stays a linear
189
255
  transcript written once to scrollback, so output remains pipeable and
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.2",
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.5.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"
@@ -38,6 +38,11 @@ const VENDOR = path.join(CLI_DIR, 'vendor');
38
38
  const FILES = [
39
39
  'client/aegis.js',
40
40
  'client/foreign-memory.js',
41
+ // The account credential store and the unified session store. Shared with the
42
+ // desktop app and the MCP plugin so all three see one key and one session
43
+ // list; `src/shared.js` resolves them from here in an installed package.
44
+ 'client/credentials.js',
45
+ 'client/session-store.js',
41
46
  'mcp/tools.js',
42
47
  'desktop/renderer/usage.js',
43
48
  // The agent-loop engine (persistent shell, editFile/grep/exec, Task
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).
@@ -151,6 +159,83 @@ function createApp(options = {}) {
151
159
  }
152
160
  }
153
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
+
154
239
  // ── the AEGIS Cloud model catalog ──────────────────────────────────────────
155
240
  //
156
241
  // `/model` (and the alt+p chord that dispatches it) reads the pinnable ids
@@ -448,7 +533,7 @@ function createApp(options = {}) {
448
533
  render.renderNotice(
449
534
  ctx(),
450
535
  'error',
451
- '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})`
452
537
  )
453
538
  );
454
539
  return;
@@ -506,7 +591,7 @@ function createApp(options = {}) {
506
591
  const tool = TOOLS[name];
507
592
  if (!tool) throw new Error(`unknown tool: ${name}`);
508
593
  if (!client.apiKey) {
509
- throw new Error('no AEGIS_API_KEY setexport one (https://aegiscloud.org)');
594
+ throw new Error(`no AEGIS account key ${credentials.HOW_TO_SET}`);
510
595
  }
511
596
  const text = await tool.run(args || {});
512
597
  emit(render.renderToolResult(ctx(), name, text, width()));
@@ -725,6 +810,17 @@ function createApp(options = {}) {
725
810
  TOOLS,
726
811
  saveConfig: (patch) => updateConfig(patch),
727
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(),
728
824
  };
729
825
  Object.defineProperty(c, 'sessionId', {
730
826
  enumerable: true,
@@ -813,7 +909,7 @@ function createApp(options = {}) {
813
909
  try {
814
910
  let args = cmd.build(arg);
815
911
  if (cmd.secret) {
816
- const key = await readSecret(`provider key for ${args.provider}: `);
912
+ const key = await askSecret(`provider key for ${args.provider}: `);
817
913
  if (!key) {
818
914
  emit(render.renderNotice(ctx(), 'warn', 'empty key — nothing sent'));
819
915
  return true;
@@ -828,52 +924,26 @@ function createApp(options = {}) {
828
924
  return true;
829
925
  }
830
926
 
831
- /** Read a secret without echoing it (raw mode; falls back to plain input). */
832
- function readSecret(promptText) {
833
- if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
834
- return Promise.resolve('');
835
- }
836
- return new Promise((resolve) => {
837
- const t = themeOf(ctx());
838
- out.write(t.gray + promptText + RESET);
839
- const stdin = process.stdin;
840
- let buf = '';
841
- stdin.setRawMode(true);
842
- stdin.resume();
843
- const onData = (chunk) => {
844
- for (const ch of chunk.toString('utf8')) {
845
- if (ch === '\r' || ch === '\n') {
846
- stdin.setRawMode(false);
847
- stdin.removeListener('data', onData);
848
- out.write('\n');
849
- return resolve(buf);
850
- }
851
- if (ch === '\x03') {
852
- // ctrl+c
853
- stdin.setRawMode(false);
854
- stdin.removeListener('data', onData);
855
- out.write('\n');
856
- return resolve('');
857
- }
858
- if (ch === '\u007f') {
859
- buf = buf.slice(0, -1);
860
- out.write('\b \b');
861
- continue;
862
- }
863
- buf += ch;
864
- out.write('•');
865
- }
866
- };
867
- stdin.on('data', onData);
868
- });
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 });
869
938
  }
870
939
 
871
940
  // --- entry points ---------------------------------------------------------
872
941
 
873
942
  /** Non-interactive: one prompt, plain output, exit code. */
874
943
  async function runOnce(prompt, { json = false } = {}) {
944
+ adoptLegacyKeyOnce();
875
945
  if (!client.apiKey) {
876
- 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`);
877
947
  return 2;
878
948
  }
879
949
  const res = await ask(prompt);
@@ -958,6 +1028,39 @@ function createApp(options = {}) {
958
1028
  } catch {
959
1029
  /* persistence is best-effort */
960
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
+ });
961
1064
  }
962
1065
 
963
1066
  /** One line of session accounting, for ctrl+t and the meta row. */ function tokenSummary() {
@@ -1115,6 +1218,7 @@ function createApp(options = {}) {
1115
1218
  process.stdout.isTTY;
1116
1219
  if (tty) {
1117
1220
  restorePrefs();
1221
+ adoptLegacyKeyOnce();
1118
1222
  // Onboarding runs in the normal buffer, before the session takes the
1119
1223
  // alternate screen — the reference's order. A declined trust check must
1120
1224
  // abort: the reference returns without ever reaching `session(ctx)`.
@@ -1122,8 +1226,31 @@ function createApp(options = {}) {
1122
1226
  continue: !!options.continue,
1123
1227
  seen: options.seen || configExists,
1124
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),
1125
1234
  });
1126
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
+ }
1127
1254
  // A stored pin the platform does not advertise routes elsewhere in
1128
1255
  // silence (see validatePinnedModel) — checked once, here, where the user
1129
1256
  // can act on it. Not on `-p`: a network round-trip ahead of the first
@@ -1143,6 +1270,7 @@ function createApp(options = {}) {
1143
1270
  return 0;
1144
1271
  }
1145
1272
  restorePrefs();
1273
+ adoptLegacyKeyOnce();
1146
1274
  return runLinearRepl();
1147
1275
  }
1148
1276
 
@@ -1168,6 +1296,13 @@ function createApp(options = {}) {
1168
1296
  recordTurn,
1169
1297
  persistTurn,
1170
1298
  restorePrefs,
1299
+ setApiKey,
1300
+ forgetApiKey,
1301
+ adoptLegacyKeyOnce,
1302
+ cloudSyncEnabled,
1303
+ setCloudSync,
1304
+ cloudsync,
1305
+ keyStatus: () => credentials.keyStatus(),
1171
1306
  sessionId: () => commandCtx.sessionId,
1172
1307
  tokenSummary,
1173
1308
  resumeSession,