@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/README.md +53 -57
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +673 -149
- package/dist/index.js.map +1 -1
- package/package.json +7 -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';
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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 —
|
|
909
|
-
|
|
910
|
-
|
|
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:
|
|
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
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
if (
|
|
1221
|
-
|
|
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
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
if (signedIn &&
|
|
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
|
|
1789
|
+
if (answer.kind !== 'index' || answer.index === 2) {
|
|
1407
1790
|
return;
|
|
1408
1791
|
}
|
|
1409
|
-
if (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.
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
if (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 ===
|
|
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 ===
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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 ===
|
|
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
|
-
|
|
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.
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
if (answer
|
|
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 ===
|
|
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.
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
if (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 ===
|
|
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
|
-
|
|
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
|
-
|
|
2051
|
-
|
|
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.
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
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 ===
|
|
2476
|
+
if (choice.index === 0) {
|
|
2067
2477
|
await chooseProviders();
|
|
2068
2478
|
}
|
|
2069
|
-
else if (choice ===
|
|
2479
|
+
else if (choice.index === 1) {
|
|
2070
2480
|
await chooseModels();
|
|
2071
2481
|
}
|
|
2072
|
-
else if (choice ===
|
|
2482
|
+
else if (choice.index === 2) {
|
|
2073
2483
|
await chooseAgent();
|
|
2074
2484
|
}
|
|
2075
|
-
else if (choice ===
|
|
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);
|