@stratusagent/cli 0.2.0 → 0.2.2

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
- import { appendFile, chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { appendFile, chmod, mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
5
  import { once } from 'node:events';
6
6
  import os from 'node:os';
@@ -10,7 +10,30 @@ import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@s
10
10
  import { createLocalCommandExecutor, defineLocalCommandTool, } from '@stratusagent/executor-local';
11
11
  import { createOpenAICompatibleProvider, createProviderResponseBuilder, defineProvider, } from '@stratusagent/providers';
12
12
  import { createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
13
- import { createRememberTool, defineAgent, formatSoul, parseSoul, } from '@stratusagent/agents';
13
+ import { createClaudeCodeProvider } from '@stratusagent/provider-claude-code';
14
+ import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
15
+ export const CLI_VERSION = '0.2.2';
16
+ // The agent every run uses when no soul is configured. A Stratus agent is
17
+ // a Stratus agent — never "the model" — whichever provider serves it.
18
+ const DEFAULT_STRATUS_AGENT = {
19
+ id: 'stratus',
20
+ name: 'Stratus',
21
+ instructions: 'You are Stratus, a personal agent on the Stratus Agent platform. Be warm, direct, and concise. When asked who or what you are, you are Stratus — a Stratus agent — regardless of which model is serving the conversation.',
22
+ };
23
+ // Unsouled runs used to remember facts under a per-provider default agent.
24
+ // Stratus inherits all of them: reads for the built-in agent also return
25
+ // entries stored under the legacy ids, while new facts land under 'stratus'.
26
+ const LEGACY_DEFAULT_AGENT_IDS = ['demo-agent', 'anthropic-agent', 'openai-agent'];
27
+ const withLegacyDefaultMemories = (store) => ({
28
+ append: (agentId, content, metadata) => store.append(agentId, content, metadata),
29
+ async list(agentId) {
30
+ if (agentId !== DEFAULT_STRATUS_AGENT.id) {
31
+ return store.list(agentId);
32
+ }
33
+ const batches = await Promise.all([DEFAULT_STRATUS_AGENT.id, ...LEGACY_DEFAULT_AGENT_IDS].map((id) => store.list(id)));
34
+ return batches.flat().sort((a, b) => a.createdAt.localeCompare(b.createdAt));
35
+ },
36
+ });
14
37
  const DEFAULT_CONFIG_FILENAME = 'stratus.config.json';
15
38
  const LEGACY_CONFIG_FILENAME = 'stratusclaw.config.json';
16
39
  const STRATUS_HOME_DIRNAME = '.stratus';
@@ -141,12 +164,102 @@ const createDemoProvider = () => defineProvider({
141
164
  return builder.done();
142
165
  },
143
166
  });
144
- const MEMORY_FILE_RELATIVE_PATH = path.join('.stratus', 'memory.jsonl');
167
+ const MEMORY_FILENAME = 'memory.jsonl';
168
+ const memoryFilePath = (env) => path.join(stratusHomePath(env), MEMORY_FILENAME);
169
+ // Memory used to live under the working directory. Fold any such file into
170
+ // the global store the first time a run happens from that directory, then
171
+ // archive it — an upgrade must never look like the agent forgot.
172
+ //
173
+ // Every import first takes exclusive ownership by atomically renaming its
174
+ // source to a unique claim file: of any competing processes, exactly one
175
+ // wins the rename and the rest see ENOENT. A crash mid-import leaves the
176
+ // claim file behind; later runs re-claim it the same way and finish the
177
+ // job, with entries deduped against the global store by id. Only records
178
+ // that parse as real memory entries are imported — malformed lines stay in
179
+ // the archive instead of poisoning the global store for every agent.
180
+ const isMemoryEntryLine = (line) => {
181
+ try {
182
+ const parsed = JSON.parse(line);
183
+ return typeof parsed === 'object' && parsed !== null
184
+ && typeof parsed.id === 'string'
185
+ && typeof parsed.agentId === 'string'
186
+ && typeof parsed.content === 'string';
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ };
192
+ const migrateLegacyMemory = async (env) => {
193
+ const legacyPath = path.join(readWorkingDirectory(env), '.stratus', MEMORY_FILENAME);
194
+ const globalPath = memoryFilePath(env);
195
+ if (legacyPath === globalPath) {
196
+ return;
197
+ }
198
+ const legacyDir = path.dirname(legacyPath);
199
+ const archivePath = `${legacyPath}.migrated`;
200
+ const claimAndImport = async (sourcePath) => {
201
+ const claimPath = path.join(legacyDir, `${MEMORY_FILENAME}.migrating-${randomUUID()}`);
202
+ try {
203
+ await rename(sourcePath, claimPath);
204
+ }
205
+ catch (error) {
206
+ if (error.code === 'ENOENT') {
207
+ return; // another process owns it, or there is nothing to migrate
208
+ }
209
+ throw error;
210
+ }
211
+ const claimed = await readFile(claimPath, 'utf8');
212
+ let existingIds;
213
+ try {
214
+ existingIds = new Set((await readFile(globalPath, 'utf8'))
215
+ .split('\n')
216
+ .filter(isMemoryEntryLine)
217
+ .map((line) => JSON.parse(line).id));
218
+ }
219
+ catch {
220
+ existingIds = new Set();
221
+ }
222
+ const entries = claimed
223
+ .split('\n')
224
+ .filter((line) => line.trim().length > 0)
225
+ .filter(isMemoryEntryLine)
226
+ .filter((line) => !existingIds.has(JSON.parse(line).id));
227
+ if (entries.length > 0) {
228
+ await mkdir(path.dirname(globalPath), { recursive: true });
229
+ await appendFile(globalPath, `${entries.join('\n')}\n`);
230
+ }
231
+ // Archive by appending (never overwriting an earlier archive), then
232
+ // drop the claim — its content is fully preserved in the archive.
233
+ if (claimed.length > 0) {
234
+ await appendFile(archivePath, claimed.endsWith('\n') || claimed.length === 0 ? claimed : `${claimed}\n`);
235
+ }
236
+ await unlink(claimPath);
237
+ };
238
+ await claimAndImport(legacyPath);
239
+ // Finish any claims a crashed run left behind (both the current unique
240
+ // names and the fixed .migrating name from earlier versions).
241
+ let leftovers;
242
+ try {
243
+ leftovers = await readdir(legacyDir);
244
+ }
245
+ catch (error) {
246
+ if (error.code === 'ENOENT') {
247
+ return;
248
+ }
249
+ throw error;
250
+ }
251
+ for (const name of leftovers) {
252
+ if (name.startsWith(`${MEMORY_FILENAME}.migrating`)) {
253
+ await claimAndImport(path.join(legacyDir, name));
254
+ }
255
+ }
256
+ };
145
257
  // Agents keep the same memory across CLI runs: every remembered fact lands
146
- // in .stratus/memory.jsonl (keyed by agent id), so the Ava you talk to
147
- // tomorrow is the Ava you talked to today. One JSON entry per line, written
148
- // with O_APPEND: concurrent runs each add their own line instead of
149
- // re-writing the file, so no run can clobber another's remembered fact.
258
+ // in ~/.stratus/memory.jsonl (keyed by agent id), so the Ava you talk to
259
+ // tomorrow — from any directory — is the Ava you talked to today. One JSON
260
+ // entry per line, written with O_APPEND: concurrent runs each add their own
261
+ // line instead of re-writing the file, so no run can clobber another's
262
+ // remembered fact.
150
263
  export const createFileMemoryStore = (filePath) => {
151
264
  const readEntries = async () => {
152
265
  let raw;
@@ -185,8 +298,16 @@ export const createFileMemoryStore = (filePath) => {
185
298
  return entry;
186
299
  },
187
300
  async list(agentId) {
188
- const entries = await readEntries();
189
- return entries.filter((entry) => entry.agentId === agentId);
301
+ // Dedupe by id at read time: even if a rare race double-imported a
302
+ // fact, the agent only ever sees it once.
303
+ const seen = new Set();
304
+ return (await readEntries()).filter((entry) => {
305
+ if (entry.agentId !== agentId || seen.has(entry.id)) {
306
+ return false;
307
+ }
308
+ seen.add(entry.id);
309
+ return true;
310
+ });
190
311
  },
191
312
  };
192
313
  };
@@ -824,6 +945,16 @@ const createRuntimeProvider = (config, onFallback) => {
824
945
  return createFallbackWrappedProvider(primary, fallbackProvider, onFallback ?? (() => { }));
825
946
  }
826
947
  if (config.provider === 'anthropic') {
948
+ // Subscription setup tokens are only honored inside the Claude Code
949
+ // harness, so they route through the Agent SDK runtime; API keys use
950
+ // the raw Messages API.
951
+ if (config.authToken && !config.apiKey) {
952
+ return createClaudeCodeProvider({
953
+ authToken: config.authToken,
954
+ model: config.model,
955
+ ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
956
+ });
957
+ }
827
958
  return createAnthropicProvider({
828
959
  model: config.model,
829
960
  ...(config.apiKey ? { apiKey: config.apiKey } : {}),
@@ -874,7 +1005,9 @@ const createApprovalPolicy = (mode, streams, env) => {
874
1005
  };
875
1006
  };
876
1007
  export const runSingleLoop = async (prompt, streams, options) => {
877
- const memory = createFileMemoryStore(path.join(readWorkingDirectory(options.env ?? {}), MEMORY_FILE_RELATIVE_PATH));
1008
+ const runEnv = options.env ?? {};
1009
+ await migrateLegacyMemory(runEnv);
1010
+ const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(runEnv)));
878
1011
  const tools = new ToolRegistry();
879
1012
  tools.register(createDemoTool());
880
1013
  tools.register(createRememberTool(memory));
@@ -905,25 +1038,9 @@ export const runSingleLoop = async (prompt, streams, options) => {
905
1038
  ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
906
1039
  });
907
1040
  await runner.initialize();
908
- // A soul is a full identity — it replaces the built-in per-provider agent.
909
- const agent = options.runtime.soul?.agent
910
- ?? (options.runtime.provider === 'demo'
911
- ? {
912
- id: 'demo-agent',
913
- name: 'Demo Agent',
914
- instructions: 'Keep the loop tiny and readable.',
915
- }
916
- : options.runtime.provider === 'anthropic'
917
- ? {
918
- id: 'anthropic-agent',
919
- name: 'Claude Agent',
920
- instructions: 'Respond clearly and directly to the user request.',
921
- }
922
- : {
923
- id: 'openai-agent',
924
- name: 'OpenAI Agent',
925
- instructions: 'Respond clearly and directly to the user request.',
926
- });
1041
+ // A soul is a full identity — without one, every provider serves the
1042
+ // same built-in Stratus persona.
1043
+ const agent = options.runtime.soul?.agent ?? DEFAULT_STRATUS_AGENT;
927
1044
  const metadata = options.runtime.provider === 'demo'
928
1045
  ? { provider: 'demo' }
929
1046
  : {
@@ -1108,7 +1225,7 @@ export const startDashboardServer = async (options) => {
1108
1225
  sendJson(response, 200, {
1109
1226
  ok: true,
1110
1227
  service: 'stratus-dashboard',
1111
- version: '0.2.0',
1228
+ version: CLI_VERSION,
1112
1229
  now: new Date().toISOString(),
1113
1230
  });
1114
1231
  return;
@@ -1164,26 +1281,227 @@ export const startDashboardServer = async (options) => {
1164
1281
  // Printed commands must survive copy-paste into a shell, so anything outside
1165
1282
  // the safe character set gets single-quoted.
1166
1283
  const quoteShellArg = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
1167
- const createSetupPrompter = (streams, env) => {
1168
- const input = env.setupInput ?? process.stdin;
1169
- // On a real TTY the readline must own the terminal so we can suppress the
1170
- // echo of pasted secrets; piped input (tests, scripts) never echoes.
1284
+ // The fixed header drawn at the top of every interactive screen.
1285
+ const stratusHeaderLines = () => {
1286
+ const title = 'Stratus Agent';
1287
+ const version = `v${CLI_VERSION}`;
1288
+ const gap = ' ';
1289
+ const content = ` ${title}${gap}${version} `;
1290
+ return [
1291
+ `\u001b[2m╭${'─'.repeat(content.length)}╮\u001b[0m`,
1292
+ `\u001b[2m│\u001b[0m \u001b[1m${title}\u001b[0m${gap}\u001b[2m${version}\u001b[0m \u001b[2m│\u001b[0m`,
1293
+ `\u001b[2m╰${'─'.repeat(content.length)}╯\u001b[0m`,
1294
+ ];
1295
+ };
1296
+ const createSetupPrompter = (streams, env, view) => {
1297
+ // On a real TTY, menus are arrow-key driven and secrets are read without
1298
+ // echo; with piped input (tests, scripts) everything is plain lines.
1171
1299
  const interactive = env.setupInput === undefined && process.stdin.isTTY === true;
1172
- const readline = createInterface(interactive
1173
- ? { input, output: process.stdout, terminal: true }
1174
- : { input, terminal: false });
1175
- let secretMode = false;
1176
1300
  if (interactive) {
1177
- const internal = readline;
1178
- const original = internal._writeToOutput?.bind(readline);
1179
- internal._writeToOutput = (chunk) => {
1180
- if (!secretMode) {
1181
- original?.(chunk);
1182
- }
1183
- // Secrets: swallow the echo entirely — the prompt itself is written
1184
- // through streams.stdout before secretMode turns on.
1301
+ let closed = false;
1302
+ process.stdin.once('end', () => {
1303
+ closed = true;
1304
+ });
1305
+ const question = (prompt, secret, prefill) => {
1306
+ const readline = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
1307
+ if (secret) {
1308
+ const internal = readline;
1309
+ const original = internal._writeToOutput?.bind(readline);
1310
+ internal._writeToOutput = (chunk) => {
1311
+ // Echo the prompt itself, swallow the typed secret.
1312
+ if (chunk.startsWith(prompt)) {
1313
+ original?.(prompt);
1314
+ }
1315
+ };
1316
+ }
1317
+ return new Promise((resolve) => {
1318
+ readline.on('SIGINT', () => {
1319
+ readline.close();
1320
+ streams.stdout.write('\n');
1321
+ process.exit(130);
1322
+ });
1323
+ readline.question(prompt, (answer) => {
1324
+ readline.close();
1325
+ if (secret) {
1326
+ streams.stdout.write('\n');
1327
+ }
1328
+ resolve(answer.trim());
1329
+ });
1330
+ if (prefill) {
1331
+ // Pre-typed and editable: backspace over it or press Enter to keep.
1332
+ readline.write(prefill);
1333
+ }
1334
+ });
1335
+ };
1336
+ // Each menu replaces the screen: clear, draw the header, carry over the
1337
+ // most recent status lines, then the menu itself.
1338
+ const drawScreen = (out) => {
1339
+ out.write('\u001b[2J\u001b[H');
1340
+ for (const line of view?.header() ?? []) {
1341
+ out.write(`${line}\n`);
1342
+ }
1343
+ const notices = view?.consumeNotices() ?? [];
1344
+ if (notices.length > 0) {
1345
+ for (const line of notices) {
1346
+ out.write(`\u001b[2m${line}\u001b[0m\n`);
1347
+ }
1348
+ out.write('\n');
1349
+ }
1350
+ };
1351
+ const selectInteractive = (heading, options, opts) => new Promise((resolve) => {
1352
+ const stdin = process.stdin;
1353
+ const out = streams.stdout;
1354
+ let index = Math.min(Math.max(opts.defaultIndex ?? 0, 0), options.length - 1);
1355
+ const wasRaw = stdin.isRaw === true;
1356
+ stdin.setRawMode?.(true);
1357
+ stdin.resume();
1358
+ const render = (redraw) => {
1359
+ if (redraw) {
1360
+ out.write(`\u001b[${options.length}A`);
1361
+ }
1362
+ else {
1363
+ drawScreen(out);
1364
+ if (heading.length > 0) {
1365
+ out.write(`${heading}\n`);
1366
+ }
1367
+ if (opts.footnote) {
1368
+ out.write(`${opts.footnote}\n`);
1369
+ }
1370
+ }
1371
+ options.forEach((option, i) => {
1372
+ const active = i === index;
1373
+ out.write(`\u001b[2K\r${active ? '\u001b[36m\u276f ' : ' '}${i + 1}) ${option}${active ? '\u001b[0m' : ''}\n`);
1374
+ });
1375
+ };
1376
+ const finish = (answer) => {
1377
+ if (pendingTimer !== undefined) {
1378
+ clearTimeout(pendingTimer);
1379
+ pendingTimer = undefined;
1380
+ }
1381
+ stdin.off('data', onData);
1382
+ if (!wasRaw) {
1383
+ stdin.setRawMode?.(false);
1384
+ }
1385
+ // Pause so keys typed between menus buffer for the next consumer
1386
+ // instead of being dropped by a flowing stream with no listener.
1387
+ stdin.pause();
1388
+ if (pendingEscape.length > 0) {
1389
+ stdin.unshift(Buffer.from(pendingEscape, 'utf8'));
1390
+ pendingEscape = '';
1391
+ }
1392
+ resolve(answer);
1393
+ };
1394
+ const handleKey = (key) => {
1395
+ if (key === '\u0003') {
1396
+ // Ctrl-C: restore the terminal and leave setup entirely.
1397
+ stdin.setRawMode?.(false);
1398
+ out.write('\n');
1399
+ process.exit(130);
1400
+ }
1401
+ if (key === '\u001b[A' || key === '\u001bOA' || key === 'k') {
1402
+ index = (index - 1 + options.length) % options.length;
1403
+ render(true);
1404
+ return false;
1405
+ }
1406
+ if (key === '\u001b[B' || key === '\u001bOB' || key === 'j' || key === '\t') {
1407
+ index = (index + 1) % options.length;
1408
+ render(true);
1409
+ return false;
1410
+ }
1411
+ if (key === '\r' || key === '\n') {
1412
+ finish({ kind: 'index', index });
1413
+ return true;
1414
+ }
1415
+ if (key === '\u001b' || key === 'q') {
1416
+ finish({ kind: 'back' });
1417
+ return true;
1418
+ }
1419
+ if (/^[1-9]$/.test(key)) {
1420
+ const jump = Number(key) - 1;
1421
+ if (jump < options.length) {
1422
+ index = jump;
1423
+ render(true);
1424
+ finish({ kind: 'index', index });
1425
+ return true;
1426
+ }
1427
+ }
1428
+ return false;
1429
+ };
1430
+ // Key repeat and pasted input arrive as one chunk containing several
1431
+ // sequences — split it into individual keys before handling. Bytes
1432
+ // that follow the selecting key (e.g. a pasted "2sk-ant-…") are
1433
+ // pushed back onto stdin for whatever prompt comes next. Terminals
1434
+ // and SSH can also split an escape sequence ACROSS chunks (ESC, then
1435
+ // "[A"), so an incomplete escape tail is held briefly: completed by
1436
+ // the next chunk, or treated as a real Esc press after a beat.
1437
+ let pendingEscape = '';
1438
+ let pendingTimer;
1439
+ const processText = (text) => {
1440
+ let position = 0;
1441
+ while (position < text.length) {
1442
+ const remaining = text.length - position;
1443
+ if (text[position] === '\u001b' && remaining < 3
1444
+ && (remaining === 1 || text[position + 1] === '[' || text[position + 1] === 'O')) {
1445
+ pendingEscape = text.slice(position);
1446
+ pendingTimer = setTimeout(() => {
1447
+ // No continuation arrived: it was a genuine Esc press.
1448
+ pendingEscape = '';
1449
+ pendingTimer = undefined;
1450
+ handleKey('\u001b');
1451
+ }, 75);
1452
+ return;
1453
+ }
1454
+ let key;
1455
+ if (text[position] === '\u001b' && (text[position + 1] === '[' || text[position + 1] === 'O')) {
1456
+ key = text.slice(position, position + 3);
1457
+ position += 3;
1458
+ }
1459
+ else {
1460
+ key = text[position];
1461
+ position += 1;
1462
+ }
1463
+ if (handleKey(key)) {
1464
+ const rest = text.slice(position);
1465
+ if (rest.length > 0) {
1466
+ stdin.unshift(Buffer.from(rest, 'utf8'));
1467
+ }
1468
+ return;
1469
+ }
1470
+ }
1471
+ };
1472
+ const onData = (chunk) => {
1473
+ if (pendingTimer !== undefined) {
1474
+ clearTimeout(pendingTimer);
1475
+ pendingTimer = undefined;
1476
+ }
1477
+ const text = pendingEscape + chunk.toString('utf8');
1478
+ pendingEscape = '';
1479
+ processText(text);
1480
+ };
1481
+ render(false);
1482
+ stdin.on('data', onData);
1483
+ });
1484
+ return {
1485
+ ask: (q, opts) => question(q, false, opts?.prefill),
1486
+ askSecret: (q) => question(q, true),
1487
+ isInteractive: () => true,
1488
+ async select(heading, options, opts = {}) {
1489
+ if (options.length === 0) {
1490
+ return { kind: 'back' };
1491
+ }
1492
+ return selectInteractive(heading, options, opts);
1493
+ },
1494
+ isClosed: () => closed,
1495
+ close: () => {
1496
+ // A resumed raw-mode stdin keeps the event loop alive; release it
1497
+ // so the process can exit once setup returns.
1498
+ process.stdin.setRawMode?.(false);
1499
+ process.stdin.pause();
1500
+ },
1185
1501
  };
1186
1502
  }
1503
+ const input = env.setupInput ?? process.stdin;
1504
+ const readline = createInterface({ input, terminal: false });
1187
1505
  const pendingLines = [];
1188
1506
  let closed = false;
1189
1507
  readline.on('line', (line) => {
@@ -1204,28 +1522,65 @@ const createSetupPrompter = (streams, env) => {
1204
1522
  }
1205
1523
  return (pendingLines.shift() ?? '').trim();
1206
1524
  };
1207
- return {
1208
- async ask(question) {
1209
- streams.stdout.write(question);
1210
- return nextLine();
1211
- },
1212
- async askSecret(question) {
1213
- streams.stdout.write(question);
1214
- secretMode = true;
1215
- try {
1216
- return await nextLine();
1525
+ const selectPlain = async (heading, options, opts) => {
1526
+ const defaultIndex = Math.min(Math.max(opts.defaultIndex ?? 0, 0), options.length - 1);
1527
+ while (true) {
1528
+ streams.stdout.write(`${heading}\n`);
1529
+ if (opts.footnote) {
1530
+ streams.stdout.write(`${opts.footnote}\n`);
1531
+ }
1532
+ for (const [i, option] of options.entries()) {
1533
+ streams.stdout.write(` ${i + 1}) ${option}\n`);
1534
+ }
1535
+ streams.stdout.write(`Choose [${defaultIndex + 1}]: `);
1536
+ const line = await nextLine();
1537
+ if (line === '') {
1538
+ if (closed && pendingLines.length === 0) {
1539
+ return { kind: 'back' };
1540
+ }
1541
+ return { kind: 'index', index: defaultIndex };
1217
1542
  }
1218
- finally {
1219
- secretMode = false;
1220
- if (interactive) {
1221
- streams.stdout.write('\n');
1543
+ if (/^\d+$/.test(line)) {
1544
+ const picked = Number(line) - 1;
1545
+ if (picked >= 0 && picked < options.length) {
1546
+ return { kind: 'index', index: picked };
1222
1547
  }
1548
+ writeLine(streams.stdout, `Pick a number between 1 and ${options.length}.`);
1549
+ continue;
1550
+ }
1551
+ if (/^(back|b|q(uit)?)$/i.test(line)) {
1552
+ return { kind: 'back' };
1223
1553
  }
1554
+ if (opts.allowText) {
1555
+ return { kind: 'text', text: line };
1556
+ }
1557
+ const matched = options.findIndex((option) => option.toLowerCase().includes(line.toLowerCase()));
1558
+ if (matched !== -1) {
1559
+ return { kind: 'index', index: matched };
1560
+ }
1561
+ writeLine(streams.stdout, `Pick a number between 1 and ${options.length}.`);
1562
+ }
1563
+ };
1564
+ return {
1565
+ async ask(q, opts) {
1566
+ streams.stdout.write(q);
1567
+ const line = await nextLine();
1568
+ return line || (opts?.prefill ?? '');
1569
+ },
1570
+ async askSecret(q) {
1571
+ // Piped input never echoes, so plain reads are safe here.
1572
+ streams.stdout.write(q);
1573
+ return nextLine();
1224
1574
  },
1225
- isClosed() {
1226
- return closed && pendingLines.length === 0;
1575
+ isInteractive: () => false,
1576
+ async select(heading, options, opts = {}) {
1577
+ if (options.length === 0) {
1578
+ return { kind: 'back' };
1579
+ }
1580
+ return selectPlain(heading, options, opts);
1227
1581
  },
1228
- close() {
1582
+ isClosed: () => closed && pendingLines.length === 0,
1583
+ close: () => {
1229
1584
  readline.close();
1230
1585
  },
1231
1586
  };
@@ -1306,6 +1661,31 @@ export const runSetup = async (command, streams, env = {}) => {
1306
1661
  writeLine(streams.stderr, `Warning: could not read ${configPath} (${error instanceof Error ? error.message : String(error)}); starting fresh.`);
1307
1662
  }
1308
1663
  }
1664
+ // Interactive mode is a screen-based interface: every menu clears and
1665
+ // redraws under a fixed header, and status lines printed between menus
1666
+ // are carried onto the next screen (dimmed) so nothing is missed.
1667
+ const interactive = env.setupInput === undefined && process.stdin.isTTY === true;
1668
+ const baseStreams = streams;
1669
+ const recentNotices = [];
1670
+ if (interactive) {
1671
+ streams = {
1672
+ stderr: baseStreams.stderr,
1673
+ stdout: {
1674
+ write(chunk) {
1675
+ for (const raw of String(chunk).split('\n')) {
1676
+ const line = raw.trimEnd();
1677
+ if (line.trim().length > 0) {
1678
+ recentNotices.push(line);
1679
+ if (recentNotices.length > 8) {
1680
+ recentNotices.shift();
1681
+ }
1682
+ }
1683
+ }
1684
+ return baseStreams.stdout.write(chunk);
1685
+ },
1686
+ },
1687
+ };
1688
+ }
1309
1689
  const state = {
1310
1690
  provider: existing.provider ?? 'anthropic',
1311
1691
  ...(existing.model ? { model: existing.model } : {}),
@@ -1323,7 +1703,10 @@ export const runSetup = async (command, streams, env = {}) => {
1323
1703
  credentials: await loadCredentials(env),
1324
1704
  credentialsDirty: false,
1325
1705
  };
1326
- const prompter = createSetupPrompter(streams, env);
1706
+ const prompter = createSetupPrompter(baseStreams, env, {
1707
+ header: stratusHeaderLines,
1708
+ consumeNotices: () => recentNotices.splice(0),
1709
+ });
1327
1710
  const defaultModelFor = (provider) => provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL;
1328
1711
  const defaultKeyEnvFor = (provider) => provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
1329
1712
  const credentialLabel = (credential) => credential.type === 'oauth_token' ? 'Claude subscription' : 'API key';
@@ -1391,22 +1774,22 @@ export const runSetup = async (command, streams, env = {}) => {
1391
1774
  };
1392
1775
  const signInAnthropic = async () => {
1393
1776
  const signedIn = state.credentials.anthropic !== undefined;
1394
- const answer = await prompter.ask('How should Stratus connect to Claude?\n'
1395
- + ' 1) Claude subscription (Pro/Max) — sign in through Claude Code, no per-token cost\n'
1396
- + ' 2) Anthropic API key — pay per use (console.anthropic.com)\n'
1397
- + ' 3) Skip for now\n'
1398
- + (signedIn ? ' 4) Sign out\n' : '')
1399
- + 'Choose [1]: ');
1400
- if (signedIn && (answer === '4' || /^sign ?out$/i.test(answer))) {
1777
+ const answer = await prompter.select('How should Stratus connect to Claude?', [
1778
+ 'Claude subscription (Pro/Max) — sign in through Claude Code, no per-token cost',
1779
+ 'Anthropic API key — pay per use (console.anthropic.com)',
1780
+ 'Skip for now',
1781
+ ...(signedIn ? ['Sign out'] : []),
1782
+ ]);
1783
+ if (signedIn && answer.kind === 'index' && answer.index === 3) {
1401
1784
  delete state.credentials.anthropic;
1402
1785
  state.credentialsDirty = true;
1403
1786
  writeLine(streams.stdout, 'Signed out of Anthropic.');
1404
1787
  return;
1405
1788
  }
1406
- if (answer === '3' || /^skip$/i.test(answer)) {
1789
+ if (answer.kind !== 'index' || answer.index === 2) {
1407
1790
  return;
1408
1791
  }
1409
- if (answer === '2' || /^(api|key)/i.test(answer)) {
1792
+ if (answer.index === 1) {
1410
1793
  const key = await prompter.askSecret('Paste your Anthropic API key (starts with sk-ant-, Enter to skip; input is hidden): ');
1411
1794
  if (!key) {
1412
1795
  writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
@@ -1554,21 +1937,21 @@ export const runSetup = async (command, streams, env = {}) => {
1554
1937
  }
1555
1938
  };
1556
1939
  const chooseProviders = async () => {
1557
- const answer = await prompter.ask('Providers — sign in to one or more:\n'
1558
- + ` 1) Claude (Anthropic) — ${providerSignInStatus('anthropic')}\n`
1559
- + ` 2) OpenAI-compatible — ${providerSignInStatus('openai')}\n`
1560
- + ' 3) Demo — built-in fake model, offline, no account\n'
1561
- + ' 4) Back\n'
1562
- + 'Choose [1]: ');
1563
- if (answer === '4' || /^back$/i.test(answer)) {
1940
+ const answer = await prompter.select('Providers — sign in to one or more:', [
1941
+ `Claude (Anthropic) — ${providerSignInStatus('anthropic')}`,
1942
+ `OpenAI-compatible — ${providerSignInStatus('openai')}`,
1943
+ 'Demo — built-in fake model, offline, no account',
1944
+ 'Back',
1945
+ ]);
1946
+ if (answer.kind !== 'index' || answer.index === 3) {
1564
1947
  return;
1565
1948
  }
1566
- if (answer === '3' || /^demo$/i.test(answer)) {
1949
+ if (answer.index === 2) {
1567
1950
  await switchDefaultProvider('demo');
1568
1951
  writeLine(streams.stdout, 'Demo selected — no sign-in needed. Mention "echo" or "tool" in a prompt to see tool calls.');
1569
1952
  return;
1570
1953
  }
1571
- if (answer === '2' || /^openai$/i.test(answer)) {
1954
+ if (answer.index === 1) {
1572
1955
  await signInOpenAI();
1573
1956
  if (state.credentials.openai) {
1574
1957
  await maybeSwitchDefault('openai');
@@ -1666,40 +2049,46 @@ export const runSetup = async (command, streams, env = {}) => {
1666
2049
  return;
1667
2050
  }
1668
2051
  const shown = available.slice(0, 30);
1669
- const lines = shown.map((entry, index) => ` ${index + 1}) ${entry.id} — ${entry.provider}`);
1670
- if (available.length > shown.length) {
1671
- lines.push(` …and ${available.length - shown.length} more — type the model id instead.`);
1672
- }
1673
- const answer = await prompter.ask(`Available models:\n${lines.join('\n')}\nChoose a number, or type a model id [1]: `);
1674
- let choice;
1675
- if (!answer) {
1676
- choice = shown[0];
1677
- }
1678
- else if (/^\d+$/.test(answer)) {
1679
- choice = shown[Number(answer) - 1];
1680
- if (!choice) {
1681
- writeLine(streams.stdout, `Pick a number between 1 and ${shown.length}.`);
1682
- return;
1683
- }
2052
+ const labels = shown.map((entry) => `${entry.id} — ${entry.provider}`);
2053
+ const typeItOption = labels.length;
2054
+ labels.push('Type a model id…');
2055
+ const footnote = available.length > shown.length
2056
+ ? ` …and ${available.length - shown.length} more — pick "Type a model id…" to name one.`
2057
+ : undefined;
2058
+ const answer = await prompter.select('Available models:', labels, {
2059
+ allowText: true,
2060
+ ...(footnote ? { footnote } : {}),
2061
+ });
2062
+ if (answer.kind === 'back') {
2063
+ return;
1684
2064
  }
1685
- else if (answer.includes(':')) {
1686
- const [providerPart, ...idParts] = answer.split(':');
1687
- const id = idParts.join(':').trim();
1688
- if ((providerPart === 'anthropic' || providerPart === 'openai') && id) {
1689
- choice = { provider: providerPart, id };
1690
- }
1691
- else {
2065
+ const parseTyped = (typed) => {
2066
+ if (typed.includes(':')) {
2067
+ const [providerPart, ...idParts] = typed.split(':');
2068
+ const id = idParts.join(':').trim();
2069
+ if ((providerPart === 'anthropic' || providerPart === 'openai') && id) {
2070
+ return { provider: providerPart, id };
2071
+ }
1692
2072
  writeLine(streams.stdout, 'Use provider:model, e.g. anthropic:claude-opus-5.');
1693
- return;
2073
+ return undefined;
1694
2074
  }
1695
- }
1696
- else {
1697
2075
  // A typed id that appears in the collected list belongs to that
1698
2076
  // provider, wherever the default currently points.
1699
- const listed = available.find((entry) => entry.id === answer);
2077
+ const listed = available.find((entry) => entry.id === typed);
1700
2078
  const inferred = listed?.provider
1701
2079
  ?? (state.provider !== 'demo' ? state.provider : available[0].provider);
1702
- choice = { provider: inferred, id: answer };
2080
+ return { provider: inferred, id: typed };
2081
+ };
2082
+ let choice;
2083
+ if (answer.kind === 'text') {
2084
+ choice = parseTyped(answer.text);
2085
+ }
2086
+ else if (answer.index === typeItOption) {
2087
+ const typed = await prompter.ask('Model id (or provider:model): ');
2088
+ choice = typed ? parseTyped(typed) : undefined;
2089
+ }
2090
+ else {
2091
+ choice = shown[answer.index];
1703
2092
  }
1704
2093
  if (!choice) {
1705
2094
  return;
@@ -1741,39 +2130,42 @@ export const runSetup = async (command, streams, env = {}) => {
1741
2130
  }
1742
2131
  };
1743
2132
  const chooseModels = async () => {
1744
- const answer = await prompter.ask(`Models — ${modelsSummary()}\n`
1745
- + ' 1) Choose the default model\n'
1746
- + ' 2) Choose a fallback model\n'
1747
- + ' 3) Clear the fallback\n'
1748
- + ' 4) Back\n'
1749
- + 'Choose [1]: ');
1750
- if (answer === '2') {
2133
+ const answer = await prompter.select(`Models — ${modelsSummary()}`, [
2134
+ 'Choose the default model',
2135
+ 'Choose a fallback model',
2136
+ 'Clear the fallback',
2137
+ 'Back',
2138
+ ]);
2139
+ if (answer.kind !== 'index' || answer.index === 3) {
2140
+ return;
2141
+ }
2142
+ if (answer.index === 1) {
1751
2143
  await pickModel('fallback');
1752
2144
  return;
1753
2145
  }
1754
- if (answer === '3') {
2146
+ if (answer.index === 2) {
1755
2147
  delete state.fallbackModel;
1756
2148
  delete state.fallbackProvider;
1757
2149
  writeLine(streams.stdout, 'Fallback cleared.');
1758
2150
  return;
1759
2151
  }
1760
- if (answer === '4' || /^back$/i.test(answer)) {
1761
- return;
1762
- }
1763
2152
  await pickModel('default');
1764
2153
  };
1765
2154
  const chooseAgent = async () => {
1766
- const answer = await prompter.ask('Your default agent:\n'
1767
- + ' 1) Create a new agent\n'
1768
- + ' 2) Use an existing soul file\n'
1769
- + ' 3) No default agent\n'
1770
- + 'Choose [1]: ');
1771
- if (answer === '3' || /^none$/i.test(answer)) {
2155
+ const answer = await prompter.select('Your default agent:', [
2156
+ 'Create a new agent',
2157
+ 'Use an existing soul file',
2158
+ 'No default agent',
2159
+ ]);
2160
+ if (answer.kind !== 'index') {
2161
+ return;
2162
+ }
2163
+ if (answer.index === 2) {
1772
2164
  delete state.soulPath;
1773
2165
  writeLine(streams.stdout, 'Cleared — runs use the built-in default agent.');
1774
2166
  return;
1775
2167
  }
1776
- if (answer === '2') {
2168
+ if (answer.index === 1) {
1777
2169
  const soulAnswer = await prompter.ask('Path to the soul file: ');
1778
2170
  if (!soulAnswer) {
1779
2171
  return;
@@ -1917,9 +2309,23 @@ export const runSetup = async (command, streams, env = {}) => {
1917
2309
  const session = await runSingleLoop('Say hello and introduce yourself in one short sentence.', streams, { events: false, runtime, env });
1918
2310
  printSessionSummary(session, streams);
1919
2311
  writeLine(streams.stdout);
2312
+ if (prompter.isInteractive()) {
2313
+ await prompter.ask('Press Enter to return to the menu… ');
2314
+ recentNotices.length = 0;
2315
+ }
1920
2316
  }
1921
2317
  catch (error) {
1922
- writeLine(streams.stdout, `Test run failed: ${error instanceof Error ? error.message : String(error)}`);
2318
+ const message = error instanceof Error ? error.message : String(error);
2319
+ writeLine(streams.stdout, `Test run failed: ${message}`);
2320
+ if (runtime.provider === 'anthropic' && runtime.authToken && /Claude Code|\b(401|403|429)\b/.test(message)) {
2321
+ writeLine(streams.stdout, 'Subscription runs go through Claude Code. Make sure it is installed (npm install -g @anthropic-ai/claude-code) and signed in (run `claude`), or sign in with an Anthropic API key instead (Providers menu → Claude → API key).');
2322
+ }
2323
+ else if (/\b429\b/.test(message)) {
2324
+ writeLine(streams.stdout, 'A 429 means the provider rate-limited the request. On a new Anthropic account this usually means no purchased credits yet — check console.anthropic.com → Billing and Limits, then try again.');
2325
+ }
2326
+ else if (/\b(401|403)\b/.test(message)) {
2327
+ writeLine(streams.stdout, 'The provider rejected the credential — re-run sign-in from the Providers menu.');
2328
+ }
1923
2329
  }
1924
2330
  };
1925
2331
  const detectEnvOverride = (primary, legacy, chosen, flagName) => {
@@ -2047,40 +2453,38 @@ export const runSetup = async (command, streams, env = {}) => {
2047
2453
  writeLine(streams.stdout, ' stratus dashboard');
2048
2454
  };
2049
2455
  try {
2050
- writeLine(streams.stdout, 'Stratus Agent setup');
2051
- writeLine(streams.stdout, 'Pick a provider, sign in, and create your agent — all from this menu.');
2456
+ if (!interactive) {
2457
+ writeLine(streams.stdout, 'Stratus Agent setup');
2458
+ writeLine(streams.stdout, 'Pick a provider, sign in, and create your agent — all from this menu.');
2459
+ }
2052
2460
  if (envConfigVar && !command.configPath) {
2053
2461
  writeLine(streams.stdout, `${envConfigVar} is set, so the config will be written to ${configPath}.`);
2054
2462
  }
2055
2463
  while (true) {
2056
2464
  writeLine(streams.stdout);
2057
- const choice = await prompter.ask(` 1) Providers ${providersSummary()}\n`
2058
- + ` 2) Models ${modelsSummary()}\n`
2059
- + ` 3) Agent ${agentSummary()}\n`
2060
- + ' 4) Test run say hello with the current settings\n'
2061
- + ' 5) Save & finish\n'
2062
- + 'Choose [1-5]: ');
2063
- if (prompter.isClosed() && choice === '') {
2465
+ const choice = await prompter.select('', [
2466
+ `Providers ${providersSummary()}`,
2467
+ `Models ${modelsSummary()}`,
2468
+ `Agent ${agentSummary()}`,
2469
+ 'Test run say hello with the current settings',
2470
+ 'Save & finish',
2471
+ ]);
2472
+ // Backing out of the top level (Esc, or the input ending) saves.
2473
+ if (choice.kind !== 'index' || choice.index === 4) {
2064
2474
  break;
2065
2475
  }
2066
- if (choice === '1') {
2476
+ if (choice.index === 0) {
2067
2477
  await chooseProviders();
2068
2478
  }
2069
- else if (choice === '2') {
2479
+ else if (choice.index === 1) {
2070
2480
  await chooseModels();
2071
2481
  }
2072
- else if (choice === '3') {
2482
+ else if (choice.index === 2) {
2073
2483
  await chooseAgent();
2074
2484
  }
2075
- else if (choice === '4') {
2485
+ else if (choice.index === 3) {
2076
2486
  await testRun();
2077
2487
  }
2078
- else if (choice === '5' || /^(save|done|finish|q(uit)?)$/i.test(choice)) {
2079
- break;
2080
- }
2081
- else {
2082
- writeLine(streams.stdout, 'Pick a number between 1 and 5.');
2083
- }
2084
2488
  }
2085
2489
  await save();
2086
2490
  return 0;
@@ -2089,7 +2493,127 @@ export const runSetup = async (command, streams, env = {}) => {
2089
2493
  prompter.close();
2090
2494
  }
2091
2495
  };
2092
- export const runAgentNew = (command, streams) => {
2496
+ export const runAgentNew = async (command, streams, env = {}) => {
2497
+ // On a real terminal, creating an agent is the same guided experience as
2498
+ // setup: a headed screen, a prefilled (editable) name, a personality, and
2499
+ // an offer to make them the default. Scripted formats and piped input
2500
+ // keep the plain one-shot output.
2501
+ const interactive = env.setupInput === undefined
2502
+ && process.stdin.isTTY === true
2503
+ && command.format === 'text';
2504
+ if (interactive) {
2505
+ const prompter = createSetupPrompter(streams, env, {
2506
+ header: stratusHeaderLines,
2507
+ consumeNotices: () => [],
2508
+ });
2509
+ try {
2510
+ streams.stdout.write('\u001b[2J\u001b[H');
2511
+ for (const line of stratusHeaderLines()) {
2512
+ writeLine(streams.stdout, line);
2513
+ }
2514
+ writeLine(streams.stdout);
2515
+ const suggested = command.name ?? generateAgentName();
2516
+ const name = (await prompter.ask('Choose your name: ', { prefill: suggested })) || suggested;
2517
+ const instructions = await prompter.ask('Describe their personality (Enter for a starter you can edit later): ', ...(command.instructions ? [{ prefill: command.instructions }] : []));
2518
+ const persona = instructions || DEFAULT_SOUL_STARTER;
2519
+ let agent = defineAgent({ name, instructions: persona });
2520
+ // Frontmatter pins what a run from this directory would actually use:
2521
+ // env vars outrank the active config file (project-local or explicit
2522
+ // first, global otherwise), and no provider anywhere falls back to
2523
+ // demo — the exact precedence of stratus run. Demo produces no pin
2524
+ // at all: the soul keeps following the machine's configuration
2525
+ // instead of demanding credentials nothing has signed in for.
2526
+ const processEnv = readProcessEnv(env);
2527
+ const configLocation = await resolveConfigLocation({}, env);
2528
+ // Creating an agent must not be blocked by a broken config — it only
2529
+ // feeds the soul's provider/model hint, so fall back to defaults.
2530
+ let activeConfig = {};
2531
+ if (configLocation) {
2532
+ try {
2533
+ activeConfig = await loadConfigFile(configLocation.path);
2534
+ }
2535
+ catch (error) {
2536
+ writeLine(streams.stdout, `Note: ignoring unreadable config ${configLocation.path} (${error instanceof Error ? error.message : String(error)}).`);
2537
+ }
2538
+ }
2539
+ const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
2540
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
2541
+ ?? activeConfig.provider
2542
+ ?? 'demo';
2543
+ // The active config's model was written for the provider named in
2544
+ // that config; it only travels into the soul when they still match.
2545
+ const configModelApplies = (activeConfig.provider ?? 'openai') === soulProvider;
2546
+ const soulModel = readNonEmptyString(processEnv.STRATUS_MODEL)
2547
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
2548
+ ?? (configModelApplies ? activeConfig.model : undefined)
2549
+ ?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
2550
+ const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
2551
+ // The name stays theirs, but the id — the soul filename and the
2552
+ // memory key — must be unique: the suggestion pool is small, so a
2553
+ // repeat name would otherwise silently overwrite an earlier agent's
2554
+ // soul and share their memory. 'wx' makes each claim atomic; on a
2555
+ // collision the id gets a fresh suffix and we try again.
2556
+ await mkdir(agentsDirPath(env), { recursive: true });
2557
+ const baseId = agent.id;
2558
+ let soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
2559
+ for (;;) {
2560
+ try {
2561
+ await writeFile(soulPath, formatSoul({ agent, ...soulPin }), { flag: 'wx' });
2562
+ break;
2563
+ }
2564
+ catch (error) {
2565
+ if (error.code !== 'EEXIST') {
2566
+ throw error;
2567
+ }
2568
+ agent = defineAgent({ id: `${baseId}-${randomUUID().slice(0, 4)}`, name, instructions: persona });
2569
+ soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
2570
+ }
2571
+ }
2572
+ const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [
2573
+ 'Yes — every stratus run talks to them',
2574
+ 'Not now',
2575
+ ]);
2576
+ let madeDefault = false;
2577
+ if (makeDefault.kind === 'index' && makeDefault.index === 0) {
2578
+ // The default agent is a machine-wide setting, so it lands in the
2579
+ // global config even when a project config is active here.
2580
+ let globalConfig;
2581
+ try {
2582
+ globalConfig = await loadConfigFile(globalConfigPath(env));
2583
+ }
2584
+ catch (error) {
2585
+ if (error.code === 'ENOENT') {
2586
+ globalConfig = {};
2587
+ }
2588
+ else {
2589
+ // A malformed config is recoverable by hand — never overwrite it.
2590
+ writeLine(streams.stdout, `Could not read ${globalConfigPath(env)} (${error instanceof Error ? error.message : String(error)}), so it was left untouched. Fix it, then make ${agent.name} the default from stratus setup.`);
2591
+ }
2592
+ }
2593
+ if (globalConfig !== undefined) {
2594
+ const config = { ...globalConfig, provider: globalConfig.provider ?? soulProvider, soul: soulPath };
2595
+ await mkdir(path.dirname(globalConfigPath(env)), { recursive: true });
2596
+ await writeFile(globalConfigPath(env), `${JSON.stringify(config, null, 2)}\n`);
2597
+ madeDefault = true;
2598
+ if (configLocation && configLocation.path !== globalConfigPath(env)) {
2599
+ writeLine(streams.stdout, `Note: ${configLocation.path} takes precedence over the global config for runs started in this directory.`);
2600
+ }
2601
+ }
2602
+ }
2603
+ writeLine(streams.stdout);
2604
+ writeLine(streams.stdout, `Say hello to ${agent.name}.`);
2605
+ writeLine(streams.stdout, `Their soul lives at ${soulPath} — edit it any time to change how they talk.`);
2606
+ writeLine(streams.stdout);
2607
+ writeLine(streams.stdout, 'Try:');
2608
+ writeLine(streams.stdout, madeDefault
2609
+ ? ' stratus run "introduce yourself"'
2610
+ : ` stratus run --soul ${quoteShellArg(soulPath)} "introduce yourself"`);
2611
+ return 0;
2612
+ }
2613
+ finally {
2614
+ prompter.close();
2615
+ }
2616
+ }
2093
2617
  const agent = defineAgent({
2094
2618
  ...(command.name ? { name: command.name } : {}),
2095
2619
  ...(command.instructions ? { instructions: command.instructions } : {}),
@@ -2157,7 +2681,7 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
2157
2681
  return 0;
2158
2682
  }
2159
2683
  if (command.command === 'agent-new') {
2160
- return runAgentNew(command, streams);
2684
+ return runAgentNew(command, streams, resolvedEnv);
2161
2685
  }
2162
2686
  if (command.command === 'setup') {
2163
2687
  return runSetup(command, streams, resolvedEnv);