@stratusagent/cli 0.2.0 → 0.2.1
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 +53 -57
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +439 -123
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
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';
|
|
@@ -141,12 +141,102 @@ const createDemoProvider = () => defineProvider({
|
|
|
141
141
|
return builder.done();
|
|
142
142
|
},
|
|
143
143
|
});
|
|
144
|
-
const
|
|
144
|
+
const MEMORY_FILENAME = 'memory.jsonl';
|
|
145
|
+
const memoryFilePath = (env) => path.join(stratusHomePath(env), MEMORY_FILENAME);
|
|
146
|
+
// Memory used to live under the working directory. Fold any such file into
|
|
147
|
+
// the global store the first time a run happens from that directory, then
|
|
148
|
+
// archive it — an upgrade must never look like the agent forgot.
|
|
149
|
+
//
|
|
150
|
+
// Every import first takes exclusive ownership by atomically renaming its
|
|
151
|
+
// source to a unique claim file: of any competing processes, exactly one
|
|
152
|
+
// wins the rename and the rest see ENOENT. A crash mid-import leaves the
|
|
153
|
+
// claim file behind; later runs re-claim it the same way and finish the
|
|
154
|
+
// job, with entries deduped against the global store by id. Only records
|
|
155
|
+
// that parse as real memory entries are imported — malformed lines stay in
|
|
156
|
+
// the archive instead of poisoning the global store for every agent.
|
|
157
|
+
const isMemoryEntryLine = (line) => {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(line);
|
|
160
|
+
return typeof parsed === 'object' && parsed !== null
|
|
161
|
+
&& typeof parsed.id === 'string'
|
|
162
|
+
&& typeof parsed.agentId === 'string'
|
|
163
|
+
&& typeof parsed.content === 'string';
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const migrateLegacyMemory = async (env) => {
|
|
170
|
+
const legacyPath = path.join(readWorkingDirectory(env), '.stratus', MEMORY_FILENAME);
|
|
171
|
+
const globalPath = memoryFilePath(env);
|
|
172
|
+
if (legacyPath === globalPath) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const legacyDir = path.dirname(legacyPath);
|
|
176
|
+
const archivePath = `${legacyPath}.migrated`;
|
|
177
|
+
const claimAndImport = async (sourcePath) => {
|
|
178
|
+
const claimPath = path.join(legacyDir, `${MEMORY_FILENAME}.migrating-${randomUUID()}`);
|
|
179
|
+
try {
|
|
180
|
+
await rename(sourcePath, claimPath);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error.code === 'ENOENT') {
|
|
184
|
+
return; // another process owns it, or there is nothing to migrate
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
const claimed = await readFile(claimPath, 'utf8');
|
|
189
|
+
let existingIds;
|
|
190
|
+
try {
|
|
191
|
+
existingIds = new Set((await readFile(globalPath, 'utf8'))
|
|
192
|
+
.split('\n')
|
|
193
|
+
.filter(isMemoryEntryLine)
|
|
194
|
+
.map((line) => JSON.parse(line).id));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
existingIds = new Set();
|
|
198
|
+
}
|
|
199
|
+
const entries = claimed
|
|
200
|
+
.split('\n')
|
|
201
|
+
.filter((line) => line.trim().length > 0)
|
|
202
|
+
.filter(isMemoryEntryLine)
|
|
203
|
+
.filter((line) => !existingIds.has(JSON.parse(line).id));
|
|
204
|
+
if (entries.length > 0) {
|
|
205
|
+
await mkdir(path.dirname(globalPath), { recursive: true });
|
|
206
|
+
await appendFile(globalPath, `${entries.join('\n')}\n`);
|
|
207
|
+
}
|
|
208
|
+
// Archive by appending (never overwriting an earlier archive), then
|
|
209
|
+
// drop the claim — its content is fully preserved in the archive.
|
|
210
|
+
if (claimed.length > 0) {
|
|
211
|
+
await appendFile(archivePath, claimed.endsWith('\n') || claimed.length === 0 ? claimed : `${claimed}\n`);
|
|
212
|
+
}
|
|
213
|
+
await unlink(claimPath);
|
|
214
|
+
};
|
|
215
|
+
await claimAndImport(legacyPath);
|
|
216
|
+
// Finish any claims a crashed run left behind (both the current unique
|
|
217
|
+
// names and the fixed .migrating name from earlier versions).
|
|
218
|
+
let leftovers;
|
|
219
|
+
try {
|
|
220
|
+
leftovers = await readdir(legacyDir);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
if (error.code === 'ENOENT') {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
for (const name of leftovers) {
|
|
229
|
+
if (name.startsWith(`${MEMORY_FILENAME}.migrating`)) {
|
|
230
|
+
await claimAndImport(path.join(legacyDir, name));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
145
234
|
// Agents keep the same memory across CLI runs: every remembered fact lands
|
|
146
|
-
// in
|
|
147
|
-
// tomorrow is the Ava you talked to today. One JSON
|
|
148
|
-
// with O_APPEND: concurrent runs each add their own
|
|
149
|
-
// re-writing the file, so no run can clobber another's
|
|
235
|
+
// in ~/.stratus/memory.jsonl (keyed by agent id), so the Ava you talk to
|
|
236
|
+
// tomorrow — from any directory — is the Ava you talked to today. One JSON
|
|
237
|
+
// entry per line, written with O_APPEND: concurrent runs each add their own
|
|
238
|
+
// line instead of re-writing the file, so no run can clobber another's
|
|
239
|
+
// remembered fact.
|
|
150
240
|
export const createFileMemoryStore = (filePath) => {
|
|
151
241
|
const readEntries = async () => {
|
|
152
242
|
let raw;
|
|
@@ -185,8 +275,16 @@ export const createFileMemoryStore = (filePath) => {
|
|
|
185
275
|
return entry;
|
|
186
276
|
},
|
|
187
277
|
async list(agentId) {
|
|
188
|
-
|
|
189
|
-
|
|
278
|
+
// Dedupe by id at read time: even if a rare race double-imported a
|
|
279
|
+
// fact, the agent only ever sees it once.
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
return (await readEntries()).filter((entry) => {
|
|
282
|
+
if (entry.agentId !== agentId || seen.has(entry.id)) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
seen.add(entry.id);
|
|
286
|
+
return true;
|
|
287
|
+
});
|
|
190
288
|
},
|
|
191
289
|
};
|
|
192
290
|
};
|
|
@@ -874,7 +972,9 @@ const createApprovalPolicy = (mode, streams, env) => {
|
|
|
874
972
|
};
|
|
875
973
|
};
|
|
876
974
|
export const runSingleLoop = async (prompt, streams, options) => {
|
|
877
|
-
const
|
|
975
|
+
const runEnv = options.env ?? {};
|
|
976
|
+
await migrateLegacyMemory(runEnv);
|
|
977
|
+
const memory = createFileMemoryStore(memoryFilePath(runEnv));
|
|
878
978
|
const tools = new ToolRegistry();
|
|
879
979
|
tools.register(createDemoTool());
|
|
880
980
|
tools.register(createRememberTool(memory));
|
|
@@ -1108,7 +1208,7 @@ export const startDashboardServer = async (options) => {
|
|
|
1108
1208
|
sendJson(response, 200, {
|
|
1109
1209
|
ok: true,
|
|
1110
1210
|
service: 'stratus-dashboard',
|
|
1111
|
-
version: '0.2.
|
|
1211
|
+
version: '0.2.1',
|
|
1112
1212
|
now: new Date().toISOString(),
|
|
1113
1213
|
});
|
|
1114
1214
|
return;
|
|
@@ -1165,25 +1265,191 @@ export const startDashboardServer = async (options) => {
|
|
|
1165
1265
|
// the safe character set gets single-quoted.
|
|
1166
1266
|
const quoteShellArg = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
1167
1267
|
const createSetupPrompter = (streams, env) => {
|
|
1168
|
-
|
|
1169
|
-
//
|
|
1170
|
-
// echo of pasted secrets; piped input (tests, scripts) never echoes.
|
|
1268
|
+
// On a real TTY, menus are arrow-key driven and secrets are read without
|
|
1269
|
+
// echo; with piped input (tests, scripts) everything is plain lines.
|
|
1171
1270
|
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
1271
|
if (interactive) {
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1272
|
+
let closed = false;
|
|
1273
|
+
process.stdin.once('end', () => {
|
|
1274
|
+
closed = true;
|
|
1275
|
+
});
|
|
1276
|
+
const question = (prompt, secret) => {
|
|
1277
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
1278
|
+
if (secret) {
|
|
1279
|
+
const internal = readline;
|
|
1280
|
+
const original = internal._writeToOutput?.bind(readline);
|
|
1281
|
+
internal._writeToOutput = (chunk) => {
|
|
1282
|
+
// Echo the prompt itself, swallow the typed secret.
|
|
1283
|
+
if (chunk.startsWith(prompt)) {
|
|
1284
|
+
original?.(prompt);
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
return new Promise((resolve) => {
|
|
1289
|
+
readline.on('SIGINT', () => {
|
|
1290
|
+
readline.close();
|
|
1291
|
+
streams.stdout.write('\n');
|
|
1292
|
+
process.exit(130);
|
|
1293
|
+
});
|
|
1294
|
+
readline.question(prompt, (answer) => {
|
|
1295
|
+
readline.close();
|
|
1296
|
+
if (secret) {
|
|
1297
|
+
streams.stdout.write('\n');
|
|
1298
|
+
}
|
|
1299
|
+
resolve(answer.trim());
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
};
|
|
1303
|
+
const selectInteractive = (heading, options, opts) => new Promise((resolve) => {
|
|
1304
|
+
const stdin = process.stdin;
|
|
1305
|
+
const out = streams.stdout;
|
|
1306
|
+
let index = Math.min(Math.max(opts.defaultIndex ?? 0, 0), options.length - 1);
|
|
1307
|
+
const wasRaw = stdin.isRaw === true;
|
|
1308
|
+
stdin.setRawMode?.(true);
|
|
1309
|
+
stdin.resume();
|
|
1310
|
+
const render = (redraw) => {
|
|
1311
|
+
if (redraw) {
|
|
1312
|
+
out.write(`\u001b[${options.length}A`);
|
|
1313
|
+
}
|
|
1314
|
+
else {
|
|
1315
|
+
out.write(`${heading}\n`);
|
|
1316
|
+
if (opts.footnote) {
|
|
1317
|
+
out.write(`${opts.footnote}\n`);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
options.forEach((option, i) => {
|
|
1321
|
+
const active = i === index;
|
|
1322
|
+
out.write(`\u001b[2K\r${active ? '\u001b[36m\u276f ' : ' '}${i + 1}) ${option}${active ? '\u001b[0m' : ''}\n`);
|
|
1323
|
+
});
|
|
1324
|
+
};
|
|
1325
|
+
const finish = (answer) => {
|
|
1326
|
+
if (pendingTimer !== undefined) {
|
|
1327
|
+
clearTimeout(pendingTimer);
|
|
1328
|
+
pendingTimer = undefined;
|
|
1329
|
+
}
|
|
1330
|
+
stdin.off('data', onData);
|
|
1331
|
+
if (!wasRaw) {
|
|
1332
|
+
stdin.setRawMode?.(false);
|
|
1333
|
+
}
|
|
1334
|
+
// Pause so keys typed between menus buffer for the next consumer
|
|
1335
|
+
// instead of being dropped by a flowing stream with no listener.
|
|
1336
|
+
stdin.pause();
|
|
1337
|
+
if (pendingEscape.length > 0) {
|
|
1338
|
+
stdin.unshift(Buffer.from(pendingEscape, 'utf8'));
|
|
1339
|
+
pendingEscape = '';
|
|
1340
|
+
}
|
|
1341
|
+
resolve(answer);
|
|
1342
|
+
};
|
|
1343
|
+
const handleKey = (key) => {
|
|
1344
|
+
if (key === '\u0003') {
|
|
1345
|
+
// Ctrl-C: restore the terminal and leave setup entirely.
|
|
1346
|
+
stdin.setRawMode?.(false);
|
|
1347
|
+
out.write('\n');
|
|
1348
|
+
process.exit(130);
|
|
1349
|
+
}
|
|
1350
|
+
if (key === '\u001b[A' || key === '\u001bOA' || key === 'k') {
|
|
1351
|
+
index = (index - 1 + options.length) % options.length;
|
|
1352
|
+
render(true);
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
if (key === '\u001b[B' || key === '\u001bOB' || key === 'j' || key === '\t') {
|
|
1356
|
+
index = (index + 1) % options.length;
|
|
1357
|
+
render(true);
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
if (key === '\r' || key === '\n') {
|
|
1361
|
+
finish({ kind: 'index', index });
|
|
1362
|
+
return true;
|
|
1363
|
+
}
|
|
1364
|
+
if (key === '\u001b' || key === 'q') {
|
|
1365
|
+
finish({ kind: 'back' });
|
|
1366
|
+
return true;
|
|
1367
|
+
}
|
|
1368
|
+
if (/^[1-9]$/.test(key)) {
|
|
1369
|
+
const jump = Number(key) - 1;
|
|
1370
|
+
if (jump < options.length) {
|
|
1371
|
+
index = jump;
|
|
1372
|
+
render(true);
|
|
1373
|
+
finish({ kind: 'index', index });
|
|
1374
|
+
return true;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
return false;
|
|
1378
|
+
};
|
|
1379
|
+
// Key repeat and pasted input arrive as one chunk containing several
|
|
1380
|
+
// sequences — split it into individual keys before handling. Bytes
|
|
1381
|
+
// that follow the selecting key (e.g. a pasted "2sk-ant-…") are
|
|
1382
|
+
// pushed back onto stdin for whatever prompt comes next. Terminals
|
|
1383
|
+
// and SSH can also split an escape sequence ACROSS chunks (ESC, then
|
|
1384
|
+
// "[A"), so an incomplete escape tail is held briefly: completed by
|
|
1385
|
+
// the next chunk, or treated as a real Esc press after a beat.
|
|
1386
|
+
let pendingEscape = '';
|
|
1387
|
+
let pendingTimer;
|
|
1388
|
+
const processText = (text) => {
|
|
1389
|
+
let position = 0;
|
|
1390
|
+
while (position < text.length) {
|
|
1391
|
+
const remaining = text.length - position;
|
|
1392
|
+
if (text[position] === '\u001b' && remaining < 3
|
|
1393
|
+
&& (remaining === 1 || text[position + 1] === '[' || text[position + 1] === 'O')) {
|
|
1394
|
+
pendingEscape = text.slice(position);
|
|
1395
|
+
pendingTimer = setTimeout(() => {
|
|
1396
|
+
// No continuation arrived: it was a genuine Esc press.
|
|
1397
|
+
pendingEscape = '';
|
|
1398
|
+
pendingTimer = undefined;
|
|
1399
|
+
handleKey('\u001b');
|
|
1400
|
+
}, 75);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
let key;
|
|
1404
|
+
if (text[position] === '\u001b' && (text[position + 1] === '[' || text[position + 1] === 'O')) {
|
|
1405
|
+
key = text.slice(position, position + 3);
|
|
1406
|
+
position += 3;
|
|
1407
|
+
}
|
|
1408
|
+
else {
|
|
1409
|
+
key = text[position];
|
|
1410
|
+
position += 1;
|
|
1411
|
+
}
|
|
1412
|
+
if (handleKey(key)) {
|
|
1413
|
+
const rest = text.slice(position);
|
|
1414
|
+
if (rest.length > 0) {
|
|
1415
|
+
stdin.unshift(Buffer.from(rest, 'utf8'));
|
|
1416
|
+
}
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
const onData = (chunk) => {
|
|
1422
|
+
if (pendingTimer !== undefined) {
|
|
1423
|
+
clearTimeout(pendingTimer);
|
|
1424
|
+
pendingTimer = undefined;
|
|
1425
|
+
}
|
|
1426
|
+
const text = pendingEscape + chunk.toString('utf8');
|
|
1427
|
+
pendingEscape = '';
|
|
1428
|
+
processText(text);
|
|
1429
|
+
};
|
|
1430
|
+
render(false);
|
|
1431
|
+
stdin.on('data', onData);
|
|
1432
|
+
});
|
|
1433
|
+
return {
|
|
1434
|
+
ask: (q) => question(q, false),
|
|
1435
|
+
askSecret: (q) => question(q, true),
|
|
1436
|
+
async select(heading, options, opts = {}) {
|
|
1437
|
+
if (options.length === 0) {
|
|
1438
|
+
return { kind: 'back' };
|
|
1439
|
+
}
|
|
1440
|
+
return selectInteractive(heading, options, opts);
|
|
1441
|
+
},
|
|
1442
|
+
isClosed: () => closed,
|
|
1443
|
+
close: () => {
|
|
1444
|
+
// A resumed raw-mode stdin keeps the event loop alive; release it
|
|
1445
|
+
// so the process can exit once setup returns.
|
|
1446
|
+
process.stdin.setRawMode?.(false);
|
|
1447
|
+
process.stdin.pause();
|
|
1448
|
+
},
|
|
1185
1449
|
};
|
|
1186
1450
|
}
|
|
1451
|
+
const input = env.setupInput ?? process.stdin;
|
|
1452
|
+
const readline = createInterface({ input, terminal: false });
|
|
1187
1453
|
const pendingLines = [];
|
|
1188
1454
|
let closed = false;
|
|
1189
1455
|
readline.on('line', (line) => {
|
|
@@ -1204,28 +1470,63 @@ const createSetupPrompter = (streams, env) => {
|
|
|
1204
1470
|
}
|
|
1205
1471
|
return (pendingLines.shift() ?? '').trim();
|
|
1206
1472
|
};
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1473
|
+
const selectPlain = async (heading, options, opts) => {
|
|
1474
|
+
const defaultIndex = Math.min(Math.max(opts.defaultIndex ?? 0, 0), options.length - 1);
|
|
1475
|
+
while (true) {
|
|
1476
|
+
streams.stdout.write(`${heading}\n`);
|
|
1477
|
+
if (opts.footnote) {
|
|
1478
|
+
streams.stdout.write(`${opts.footnote}\n`);
|
|
1479
|
+
}
|
|
1480
|
+
for (const [i, option] of options.entries()) {
|
|
1481
|
+
streams.stdout.write(` ${i + 1}) ${option}\n`);
|
|
1482
|
+
}
|
|
1483
|
+
streams.stdout.write(`Choose [${defaultIndex + 1}]: `);
|
|
1484
|
+
const line = await nextLine();
|
|
1485
|
+
if (line === '') {
|
|
1486
|
+
if (closed && pendingLines.length === 0) {
|
|
1487
|
+
return { kind: 'back' };
|
|
1488
|
+
}
|
|
1489
|
+
return { kind: 'index', index: defaultIndex };
|
|
1217
1490
|
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
if (
|
|
1221
|
-
|
|
1491
|
+
if (/^\d+$/.test(line)) {
|
|
1492
|
+
const picked = Number(line) - 1;
|
|
1493
|
+
if (picked >= 0 && picked < options.length) {
|
|
1494
|
+
return { kind: 'index', index: picked };
|
|
1222
1495
|
}
|
|
1496
|
+
writeLine(streams.stdout, `Pick a number between 1 and ${options.length}.`);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
if (/^(back|b|q(uit)?)$/i.test(line)) {
|
|
1500
|
+
return { kind: 'back' };
|
|
1501
|
+
}
|
|
1502
|
+
if (opts.allowText) {
|
|
1503
|
+
return { kind: 'text', text: line };
|
|
1504
|
+
}
|
|
1505
|
+
const matched = options.findIndex((option) => option.toLowerCase().includes(line.toLowerCase()));
|
|
1506
|
+
if (matched !== -1) {
|
|
1507
|
+
return { kind: 'index', index: matched };
|
|
1223
1508
|
}
|
|
1509
|
+
writeLine(streams.stdout, `Pick a number between 1 and ${options.length}.`);
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
return {
|
|
1513
|
+
async ask(q) {
|
|
1514
|
+
streams.stdout.write(q);
|
|
1515
|
+
return nextLine();
|
|
1224
1516
|
},
|
|
1225
|
-
|
|
1226
|
-
|
|
1517
|
+
async askSecret(q) {
|
|
1518
|
+
// Piped input never echoes, so plain reads are safe here.
|
|
1519
|
+
streams.stdout.write(q);
|
|
1520
|
+
return nextLine();
|
|
1521
|
+
},
|
|
1522
|
+
async select(heading, options, opts = {}) {
|
|
1523
|
+
if (options.length === 0) {
|
|
1524
|
+
return { kind: 'back' };
|
|
1525
|
+
}
|
|
1526
|
+
return selectPlain(heading, options, opts);
|
|
1227
1527
|
},
|
|
1228
|
-
|
|
1528
|
+
isClosed: () => closed && pendingLines.length === 0,
|
|
1529
|
+
close: () => {
|
|
1229
1530
|
readline.close();
|
|
1230
1531
|
},
|
|
1231
1532
|
};
|
|
@@ -1391,22 +1692,22 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1391
1692
|
};
|
|
1392
1693
|
const signInAnthropic = async () => {
|
|
1393
1694
|
const signedIn = state.credentials.anthropic !== undefined;
|
|
1394
|
-
const answer = await prompter.
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
if (signedIn &&
|
|
1695
|
+
const answer = await prompter.select('How should Stratus connect to Claude?', [
|
|
1696
|
+
'Claude subscription (Pro/Max) — sign in through Claude Code, no per-token cost',
|
|
1697
|
+
'Anthropic API key — pay per use (console.anthropic.com)',
|
|
1698
|
+
'Skip for now',
|
|
1699
|
+
...(signedIn ? ['Sign out'] : []),
|
|
1700
|
+
]);
|
|
1701
|
+
if (signedIn && answer.kind === 'index' && answer.index === 3) {
|
|
1401
1702
|
delete state.credentials.anthropic;
|
|
1402
1703
|
state.credentialsDirty = true;
|
|
1403
1704
|
writeLine(streams.stdout, 'Signed out of Anthropic.');
|
|
1404
1705
|
return;
|
|
1405
1706
|
}
|
|
1406
|
-
if (answer
|
|
1707
|
+
if (answer.kind !== 'index' || answer.index === 2) {
|
|
1407
1708
|
return;
|
|
1408
1709
|
}
|
|
1409
|
-
if (answer ===
|
|
1710
|
+
if (answer.index === 1) {
|
|
1410
1711
|
const key = await prompter.askSecret('Paste your Anthropic API key (starts with sk-ant-, Enter to skip; input is hidden): ');
|
|
1411
1712
|
if (!key) {
|
|
1412
1713
|
writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
|
|
@@ -1554,21 +1855,21 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1554
1855
|
}
|
|
1555
1856
|
};
|
|
1556
1857
|
const chooseProviders = async () => {
|
|
1557
|
-
const answer = await prompter.
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
if (answer
|
|
1858
|
+
const answer = await prompter.select('Providers — sign in to one or more:', [
|
|
1859
|
+
`Claude (Anthropic) — ${providerSignInStatus('anthropic')}`,
|
|
1860
|
+
`OpenAI-compatible — ${providerSignInStatus('openai')}`,
|
|
1861
|
+
'Demo — built-in fake model, offline, no account',
|
|
1862
|
+
'Back',
|
|
1863
|
+
]);
|
|
1864
|
+
if (answer.kind !== 'index' || answer.index === 3) {
|
|
1564
1865
|
return;
|
|
1565
1866
|
}
|
|
1566
|
-
if (answer ===
|
|
1867
|
+
if (answer.index === 2) {
|
|
1567
1868
|
await switchDefaultProvider('demo');
|
|
1568
1869
|
writeLine(streams.stdout, 'Demo selected — no sign-in needed. Mention "echo" or "tool" in a prompt to see tool calls.');
|
|
1569
1870
|
return;
|
|
1570
1871
|
}
|
|
1571
|
-
if (answer ===
|
|
1872
|
+
if (answer.index === 1) {
|
|
1572
1873
|
await signInOpenAI();
|
|
1573
1874
|
if (state.credentials.openai) {
|
|
1574
1875
|
await maybeSwitchDefault('openai');
|
|
@@ -1666,40 +1967,46 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1666
1967
|
return;
|
|
1667
1968
|
}
|
|
1668
1969
|
const shown = available.slice(0, 30);
|
|
1669
|
-
const
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
writeLine(streams.stdout, `Pick a number between 1 and ${shown.length}.`);
|
|
1682
|
-
return;
|
|
1683
|
-
}
|
|
1970
|
+
const labels = shown.map((entry) => `${entry.id} — ${entry.provider}`);
|
|
1971
|
+
const typeItOption = labels.length;
|
|
1972
|
+
labels.push('Type a model id…');
|
|
1973
|
+
const footnote = available.length > shown.length
|
|
1974
|
+
? ` …and ${available.length - shown.length} more — pick "Type a model id…" to name one.`
|
|
1975
|
+
: undefined;
|
|
1976
|
+
const answer = await prompter.select('Available models:', labels, {
|
|
1977
|
+
allowText: true,
|
|
1978
|
+
...(footnote ? { footnote } : {}),
|
|
1979
|
+
});
|
|
1980
|
+
if (answer.kind === 'back') {
|
|
1981
|
+
return;
|
|
1684
1982
|
}
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1983
|
+
const parseTyped = (typed) => {
|
|
1984
|
+
if (typed.includes(':')) {
|
|
1985
|
+
const [providerPart, ...idParts] = typed.split(':');
|
|
1986
|
+
const id = idParts.join(':').trim();
|
|
1987
|
+
if ((providerPart === 'anthropic' || providerPart === 'openai') && id) {
|
|
1988
|
+
return { provider: providerPart, id };
|
|
1989
|
+
}
|
|
1692
1990
|
writeLine(streams.stdout, 'Use provider:model, e.g. anthropic:claude-opus-5.');
|
|
1693
|
-
return;
|
|
1991
|
+
return undefined;
|
|
1694
1992
|
}
|
|
1695
|
-
}
|
|
1696
|
-
else {
|
|
1697
1993
|
// A typed id that appears in the collected list belongs to that
|
|
1698
1994
|
// provider, wherever the default currently points.
|
|
1699
|
-
const listed = available.find((entry) => entry.id ===
|
|
1995
|
+
const listed = available.find((entry) => entry.id === typed);
|
|
1700
1996
|
const inferred = listed?.provider
|
|
1701
1997
|
?? (state.provider !== 'demo' ? state.provider : available[0].provider);
|
|
1702
|
-
|
|
1998
|
+
return { provider: inferred, id: typed };
|
|
1999
|
+
};
|
|
2000
|
+
let choice;
|
|
2001
|
+
if (answer.kind === 'text') {
|
|
2002
|
+
choice = parseTyped(answer.text);
|
|
2003
|
+
}
|
|
2004
|
+
else if (answer.index === typeItOption) {
|
|
2005
|
+
const typed = await prompter.ask('Model id (or provider:model): ');
|
|
2006
|
+
choice = typed ? parseTyped(typed) : undefined;
|
|
2007
|
+
}
|
|
2008
|
+
else {
|
|
2009
|
+
choice = shown[answer.index];
|
|
1703
2010
|
}
|
|
1704
2011
|
if (!choice) {
|
|
1705
2012
|
return;
|
|
@@ -1741,39 +2048,42 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1741
2048
|
}
|
|
1742
2049
|
};
|
|
1743
2050
|
const chooseModels = async () => {
|
|
1744
|
-
const answer = await prompter.
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
if (answer
|
|
2051
|
+
const answer = await prompter.select(`Models — ${modelsSummary()}`, [
|
|
2052
|
+
'Choose the default model',
|
|
2053
|
+
'Choose a fallback model',
|
|
2054
|
+
'Clear the fallback',
|
|
2055
|
+
'Back',
|
|
2056
|
+
]);
|
|
2057
|
+
if (answer.kind !== 'index' || answer.index === 3) {
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
if (answer.index === 1) {
|
|
1751
2061
|
await pickModel('fallback');
|
|
1752
2062
|
return;
|
|
1753
2063
|
}
|
|
1754
|
-
if (answer ===
|
|
2064
|
+
if (answer.index === 2) {
|
|
1755
2065
|
delete state.fallbackModel;
|
|
1756
2066
|
delete state.fallbackProvider;
|
|
1757
2067
|
writeLine(streams.stdout, 'Fallback cleared.');
|
|
1758
2068
|
return;
|
|
1759
2069
|
}
|
|
1760
|
-
if (answer === '4' || /^back$/i.test(answer)) {
|
|
1761
|
-
return;
|
|
1762
|
-
}
|
|
1763
2070
|
await pickModel('default');
|
|
1764
2071
|
};
|
|
1765
2072
|
const chooseAgent = async () => {
|
|
1766
|
-
const answer = await prompter.
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
if (answer
|
|
2073
|
+
const answer = await prompter.select('Your default agent:', [
|
|
2074
|
+
'Create a new agent',
|
|
2075
|
+
'Use an existing soul file',
|
|
2076
|
+
'No default agent',
|
|
2077
|
+
]);
|
|
2078
|
+
if (answer.kind !== 'index') {
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
if (answer.index === 2) {
|
|
1772
2082
|
delete state.soulPath;
|
|
1773
2083
|
writeLine(streams.stdout, 'Cleared — runs use the built-in default agent.');
|
|
1774
2084
|
return;
|
|
1775
2085
|
}
|
|
1776
|
-
if (answer ===
|
|
2086
|
+
if (answer.index === 1) {
|
|
1777
2087
|
const soulAnswer = await prompter.ask('Path to the soul file: ');
|
|
1778
2088
|
if (!soulAnswer) {
|
|
1779
2089
|
return;
|
|
@@ -1919,7 +2229,17 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1919
2229
|
writeLine(streams.stdout);
|
|
1920
2230
|
}
|
|
1921
2231
|
catch (error) {
|
|
1922
|
-
|
|
2232
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2233
|
+
writeLine(streams.stdout, `Test run failed: ${message}`);
|
|
2234
|
+
if (runtime.provider === 'anthropic' && runtime.authToken && /\b(401|403|429)\b/.test(message)) {
|
|
2235
|
+
writeLine(streams.stdout, 'Subscription setup tokens are built for the Claude Code integration, which Stratus does not route through yet — sign in with an Anthropic API key instead (Providers menu → Claude → API key).');
|
|
2236
|
+
}
|
|
2237
|
+
else if (/\b429\b/.test(message)) {
|
|
2238
|
+
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.');
|
|
2239
|
+
}
|
|
2240
|
+
else if (/\b(401|403)\b/.test(message)) {
|
|
2241
|
+
writeLine(streams.stdout, 'The provider rejected the credential — re-run sign-in from the Providers menu.');
|
|
2242
|
+
}
|
|
1923
2243
|
}
|
|
1924
2244
|
};
|
|
1925
2245
|
const detectEnvOverride = (primary, legacy, chosen, flagName) => {
|
|
@@ -2054,33 +2374,29 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2054
2374
|
}
|
|
2055
2375
|
while (true) {
|
|
2056
2376
|
writeLine(streams.stdout);
|
|
2057
|
-
const choice = await prompter.
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2377
|
+
const choice = await prompter.select('', [
|
|
2378
|
+
`Providers ${providersSummary()}`,
|
|
2379
|
+
`Models ${modelsSummary()}`,
|
|
2380
|
+
`Agent ${agentSummary()}`,
|
|
2381
|
+
'Test run say hello with the current settings',
|
|
2382
|
+
'Save & finish',
|
|
2383
|
+
]);
|
|
2384
|
+
// Backing out of the top level (Esc, or the input ending) saves.
|
|
2385
|
+
if (choice.kind !== 'index' || choice.index === 4) {
|
|
2064
2386
|
break;
|
|
2065
2387
|
}
|
|
2066
|
-
if (choice ===
|
|
2388
|
+
if (choice.index === 0) {
|
|
2067
2389
|
await chooseProviders();
|
|
2068
2390
|
}
|
|
2069
|
-
else if (choice ===
|
|
2391
|
+
else if (choice.index === 1) {
|
|
2070
2392
|
await chooseModels();
|
|
2071
2393
|
}
|
|
2072
|
-
else if (choice ===
|
|
2394
|
+
else if (choice.index === 2) {
|
|
2073
2395
|
await chooseAgent();
|
|
2074
2396
|
}
|
|
2075
|
-
else if (choice ===
|
|
2397
|
+
else if (choice.index === 3) {
|
|
2076
2398
|
await testRun();
|
|
2077
2399
|
}
|
|
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
2400
|
}
|
|
2085
2401
|
await save();
|
|
2086
2402
|
return 0;
|