@stratusagent/cli 0.1.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 +43 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1595 -180
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { 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
|
+
import os from 'node:os';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { createInterface } from 'node:readline';
|
|
8
9
|
import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@stratusagent/core';
|
|
9
10
|
import { createLocalCommandExecutor, defineLocalCommandTool, } from '@stratusagent/executor-local';
|
|
10
11
|
import { createOpenAICompatibleProvider, createProviderResponseBuilder, defineProvider, } from '@stratusagent/providers';
|
|
11
|
-
import {
|
|
12
|
+
import { createAnthropicProvider, DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
|
|
13
|
+
import { createRememberTool, defineAgent, formatSoul, parseSoul, } from '@stratusagent/agents';
|
|
12
14
|
const DEFAULT_CONFIG_FILENAME = 'stratus.config.json';
|
|
13
15
|
const LEGACY_CONFIG_FILENAME = 'stratusclaw.config.json';
|
|
16
|
+
const STRATUS_HOME_DIRNAME = '.stratus';
|
|
17
|
+
const GLOBAL_CONFIG_FILENAME = 'config.json';
|
|
18
|
+
const CREDENTIALS_FILENAME = 'credentials.json';
|
|
19
|
+
const AGENTS_DIRNAME = 'agents';
|
|
20
|
+
const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
|
|
14
21
|
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
|
15
22
|
const DEFAULT_OPENAI_MODEL = 'gpt-4.1-mini';
|
|
16
23
|
const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
|
|
@@ -21,6 +28,8 @@ Usage:
|
|
|
21
28
|
stratus setup
|
|
22
29
|
stratus run --prompt "Use the demo tool"
|
|
23
30
|
stratus run "Say hello"
|
|
31
|
+
ANTHROPIC_API_KEY=... stratus run --provider anthropic "Say hello"
|
|
32
|
+
stratus run --soul ./examples/souls/ava.md "Say hello"
|
|
24
33
|
echo "Use the echo tool" | stratus run --stdin
|
|
25
34
|
STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
|
|
26
35
|
stratus run --config ./stratus.config.json --provider openai "Say hello"
|
|
@@ -28,7 +37,10 @@ Usage:
|
|
|
28
37
|
stratus dashboard --port 4123 --host 0.0.0.0 --no-open
|
|
29
38
|
|
|
30
39
|
Commands:
|
|
31
|
-
setup
|
|
40
|
+
setup Menu-driven onboarding: pick a provider, sign in (Claude
|
|
41
|
+
subscription or API key), create your agent, and test it —
|
|
42
|
+
settings go to ~/.stratus/config.json, sign-ins to
|
|
43
|
+
~/.stratus/credentials.json (0600)
|
|
32
44
|
run Execute one local Stratus Agent session
|
|
33
45
|
agent new Create an agent identity (generates a human-ish name + avatar theme)
|
|
34
46
|
dashboard Start the local Stratus Agent dashboard and open it in your browser
|
|
@@ -37,14 +49,15 @@ Commands:
|
|
|
37
49
|
Agent options:
|
|
38
50
|
--name Agent name (omit to have one generated)
|
|
39
51
|
--instructions The agent's persona/instructions
|
|
40
|
-
--format Output format for agent new: text or
|
|
52
|
+
--format Output format for agent new: text, json, or soul (a ready-to-edit soul file)
|
|
41
53
|
|
|
42
54
|
Options:
|
|
43
55
|
--prompt, -p Prompt to send to the local agent loop
|
|
44
56
|
--stdin Read the prompt from stdin
|
|
45
|
-
--provider Provider to use:
|
|
46
|
-
--model Model name for real providers (default: gpt-4.1-mini)
|
|
47
|
-
--base-url Override the
|
|
57
|
+
--provider Provider to use: anthropic, openai, or demo
|
|
58
|
+
--model Model name for real providers (anthropic default: ${DEFAULT_ANTHROPIC_MODEL}, openai default: gpt-4.1-mini)
|
|
59
|
+
--base-url Override the provider API base URL
|
|
60
|
+
--soul Run as the agent defined by a soul file (markdown + frontmatter, see examples/souls)
|
|
48
61
|
--config Config file path (run: load settings from it, setup: write it)
|
|
49
62
|
--format Output format: text or json (default: text)
|
|
50
63
|
--no-events Hide event-by-event progress lines in text mode
|
|
@@ -56,8 +69,14 @@ Options:
|
|
|
56
69
|
--help, -h Show this help message
|
|
57
70
|
|
|
58
71
|
Config file:
|
|
59
|
-
The CLI looks for ./stratus.config.json
|
|
72
|
+
The CLI looks for ./stratus.config.json first, then a path from --config / STRATUS_CONFIG,
|
|
73
|
+
then the global ~/.stratus/config.json written by \`stratus setup\`.
|
|
74
|
+
A "soul" key (or STRATUS_SOUL) points at a soul file so every run uses that agent.
|
|
60
75
|
Legacy STRATUSCLAW_* env vars and stratusclaw.config.json are still supported for compatibility.
|
|
76
|
+
|
|
77
|
+
Soul files:
|
|
78
|
+
A soul file is markdown with frontmatter (name, provider, model, tools, credentials)
|
|
79
|
+
followed by the agent's persona in prose. See examples/souls/ava.md.
|
|
61
80
|
`;
|
|
62
81
|
const writeLine = (stream, line = '') => {
|
|
63
82
|
stream.write(`${line}\n`);
|
|
@@ -122,6 +141,153 @@ const createDemoProvider = () => defineProvider({
|
|
|
122
141
|
return builder.done();
|
|
123
142
|
},
|
|
124
143
|
});
|
|
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
|
+
};
|
|
234
|
+
// Agents keep the same memory across CLI runs: every remembered fact lands
|
|
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.
|
|
240
|
+
export const createFileMemoryStore = (filePath) => {
|
|
241
|
+
const readEntries = async () => {
|
|
242
|
+
let raw;
|
|
243
|
+
try {
|
|
244
|
+
raw = await readFile(filePath, 'utf8');
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (error.code === 'ENOENT') {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
return raw
|
|
253
|
+
.split('\n')
|
|
254
|
+
.filter((line) => line.trim().length > 0)
|
|
255
|
+
.map((line) => {
|
|
256
|
+
try {
|
|
257
|
+
return JSON.parse(line);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
throw new Error(`Memory file has an invalid line: ${filePath}`);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
async append(agentId, content, metadata) {
|
|
266
|
+
const entry = {
|
|
267
|
+
id: `${agentId}:memory:${randomUUID()}`,
|
|
268
|
+
agentId,
|
|
269
|
+
content,
|
|
270
|
+
createdAt: new Date().toISOString(),
|
|
271
|
+
...(metadata ? { metadata } : {}),
|
|
272
|
+
};
|
|
273
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
274
|
+
await appendFile(filePath, `${JSON.stringify(entry)}\n`);
|
|
275
|
+
return entry;
|
|
276
|
+
},
|
|
277
|
+
async list(agentId) {
|
|
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
|
+
});
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
};
|
|
125
291
|
const readPromptFromEnvironment = (env) => (env.stdin ?? '').trim();
|
|
126
292
|
const readPromptFromStdin = async (stdin) => {
|
|
127
293
|
stdin.setEncoding('utf8');
|
|
@@ -132,13 +298,53 @@ const readPromptFromStdin = async (stdin) => {
|
|
|
132
298
|
return data.trim();
|
|
133
299
|
};
|
|
134
300
|
const parseProviderName = (value, label) => {
|
|
135
|
-
if (value === 'demo' || value === 'openai') {
|
|
301
|
+
if (value === 'demo' || value === 'openai' || value === 'anthropic') {
|
|
136
302
|
return value;
|
|
137
303
|
}
|
|
138
304
|
throw new Error(`Unsupported provider in ${label}: ${value}`);
|
|
139
305
|
};
|
|
140
306
|
const readProcessEnv = (env) => env.processEnv ?? process.env;
|
|
141
307
|
const readWorkingDirectory = (env) => env.cwd ?? process.cwd();
|
|
308
|
+
const readHomeDirectory = (env) => env.homeDir ?? os.homedir();
|
|
309
|
+
const stratusHomePath = (env) => path.join(readHomeDirectory(env), STRATUS_HOME_DIRNAME);
|
|
310
|
+
const globalConfigPath = (env) => path.join(stratusHomePath(env), GLOBAL_CONFIG_FILENAME);
|
|
311
|
+
const credentialsPath = (env) => path.join(stratusHomePath(env), CREDENTIALS_FILENAME);
|
|
312
|
+
const agentsDirPath = (env) => path.join(stratusHomePath(env), AGENTS_DIRNAME);
|
|
313
|
+
const loadCredentials = async (env) => {
|
|
314
|
+
let raw;
|
|
315
|
+
try {
|
|
316
|
+
raw = await readFile(credentialsPath(env), 'utf8');
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (error.code === 'ENOENT') {
|
|
320
|
+
return {};
|
|
321
|
+
}
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
const parsed = JSON.parse(raw);
|
|
325
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
326
|
+
throw new Error(`Credentials file must contain a JSON object: ${credentialsPath(env)}`);
|
|
327
|
+
}
|
|
328
|
+
const credentials = {};
|
|
329
|
+
for (const provider of ['anthropic', 'openai']) {
|
|
330
|
+
const entry = parsed[provider];
|
|
331
|
+
if (typeof entry === 'object' && entry !== null && !Array.isArray(entry) &&
|
|
332
|
+
(entry.type === 'api_key' || entry.type === 'oauth_token') &&
|
|
333
|
+
typeof entry.value === 'string') {
|
|
334
|
+
credentials[provider] = entry;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return credentials;
|
|
338
|
+
};
|
|
339
|
+
// Credentials never live in a project directory or a shell profile — they
|
|
340
|
+
// are written once by `stratus setup` and read on every run, 0600 so only
|
|
341
|
+
// the owner can read them.
|
|
342
|
+
const saveCredentials = async (env, credentials) => {
|
|
343
|
+
const filePath = credentialsPath(env);
|
|
344
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
345
|
+
await writeFile(filePath, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
346
|
+
await chmod(filePath, 0o600);
|
|
347
|
+
};
|
|
142
348
|
const readOptionValue = (tokens, index, flag) => {
|
|
143
349
|
const value = tokens[index + 1];
|
|
144
350
|
if (!value) {
|
|
@@ -216,7 +422,7 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
216
422
|
}
|
|
217
423
|
if (token === '--format') {
|
|
218
424
|
const value = readOptionValue(agentRest, index, '--format');
|
|
219
|
-
if (value !== 'text' && value !== 'json') {
|
|
425
|
+
if (value !== 'text' && value !== 'json' && value !== 'soul') {
|
|
220
426
|
throw new Error(`Unsupported format: ${value}`);
|
|
221
427
|
}
|
|
222
428
|
format = value;
|
|
@@ -259,6 +465,7 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
259
465
|
let model;
|
|
260
466
|
let baseUrl;
|
|
261
467
|
let configPath;
|
|
468
|
+
let soul;
|
|
262
469
|
let format = 'text';
|
|
263
470
|
let events = true;
|
|
264
471
|
let approvals = 'always';
|
|
@@ -302,6 +509,11 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
302
509
|
index += 1;
|
|
303
510
|
continue;
|
|
304
511
|
}
|
|
512
|
+
if (token === '--soul') {
|
|
513
|
+
soul = readOptionValue(rest, index, '--soul');
|
|
514
|
+
index += 1;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
305
517
|
if (token === '--format') {
|
|
306
518
|
const value = readOptionValue(rest, index, '--format');
|
|
307
519
|
if (value !== 'text' && value !== 'json') {
|
|
@@ -357,6 +569,7 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
357
569
|
...(model ? { model } : {}),
|
|
358
570
|
...(baseUrl ? { baseUrl } : {}),
|
|
359
571
|
...(configPath ? { configPath } : {}),
|
|
572
|
+
...(soul ? { soul } : {}),
|
|
360
573
|
format,
|
|
361
574
|
events,
|
|
362
575
|
approvals,
|
|
@@ -408,20 +621,38 @@ const loadConfigFile = async (configPath) => {
|
|
|
408
621
|
if (typeof config.systemPrompt === 'string' && config.systemPrompt.length > 0) {
|
|
409
622
|
resolved.systemPrompt = config.systemPrompt;
|
|
410
623
|
}
|
|
624
|
+
if (typeof config.soul === 'string' && config.soul.length > 0) {
|
|
625
|
+
resolved.soul = config.soul;
|
|
626
|
+
}
|
|
627
|
+
if (typeof config.fallbackModel === 'string' && config.fallbackModel.length > 0) {
|
|
628
|
+
resolved.fallbackModel = config.fallbackModel;
|
|
629
|
+
}
|
|
630
|
+
if (typeof config.fallbackProvider === 'string') {
|
|
631
|
+
resolved.fallbackProvider = parseProviderName(config.fallbackProvider, `config ${configPath}`);
|
|
632
|
+
}
|
|
633
|
+
if (typeof config.fallbackBaseUrl === 'string' && config.fallbackBaseUrl.length > 0) {
|
|
634
|
+
resolved.fallbackBaseUrl = config.fallbackBaseUrl;
|
|
635
|
+
}
|
|
411
636
|
return resolved;
|
|
412
637
|
};
|
|
413
|
-
const
|
|
638
|
+
const resolveConfigLocation = async (command, env) => {
|
|
414
639
|
const processEnv = readProcessEnv(env);
|
|
415
640
|
const cwd = readWorkingDirectory(env);
|
|
416
641
|
const explicit = command.configPath ?? processEnv.STRATUS_CONFIG ?? processEnv.STRATUSCLAW_CONFIG;
|
|
417
642
|
if (explicit) {
|
|
418
|
-
return path.resolve(cwd, explicit);
|
|
643
|
+
return { path: path.resolve(cwd, explicit), trusted: true };
|
|
419
644
|
}
|
|
420
|
-
|
|
421
|
-
|
|
645
|
+
// Project-local configs win; the global ~/.stratus/config.json written by
|
|
646
|
+
// `stratus setup` is the fallback that makes the CLI work from anywhere.
|
|
647
|
+
const candidates = [
|
|
648
|
+
{ path: path.join(cwd, DEFAULT_CONFIG_FILENAME), trusted: false },
|
|
649
|
+
{ path: path.join(cwd, LEGACY_CONFIG_FILENAME), trusted: false },
|
|
650
|
+
{ path: globalConfigPath(env), trusted: true },
|
|
651
|
+
];
|
|
652
|
+
for (const candidate of candidates) {
|
|
422
653
|
try {
|
|
423
|
-
await readFile(
|
|
424
|
-
return
|
|
654
|
+
await readFile(candidate.path, 'utf8');
|
|
655
|
+
return candidate;
|
|
425
656
|
}
|
|
426
657
|
catch (error) {
|
|
427
658
|
const code = error.code;
|
|
@@ -442,44 +673,145 @@ const readNonEmptyString = (value, map) => {
|
|
|
442
673
|
}
|
|
443
674
|
return map ? map(trimmed) : trimmed;
|
|
444
675
|
};
|
|
676
|
+
// A soul travels with the run: --soul outranks STRATUS_SOUL, which outranks
|
|
677
|
+
// the config file's "soul" key.
|
|
678
|
+
const resolveSoul = async (command, env, fileConfig) => {
|
|
679
|
+
const processEnv = readProcessEnv(env);
|
|
680
|
+
const soulPath = command.soul
|
|
681
|
+
?? readNonEmptyString(processEnv.STRATUS_SOUL)
|
|
682
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
|
|
683
|
+
?? fileConfig.soul;
|
|
684
|
+
if (!soulPath) {
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
const resolvedPath = path.resolve(readWorkingDirectory(env), String(soulPath));
|
|
688
|
+
let raw;
|
|
689
|
+
try {
|
|
690
|
+
raw = await readFile(resolvedPath, 'utf8');
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
if (error.code === 'ENOENT') {
|
|
694
|
+
throw new Error(`Soul file not found: ${resolvedPath}`);
|
|
695
|
+
}
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
// Seeding with the resolved path keeps an unnamed soul's generated
|
|
700
|
+
// identity (name, id, avatar) stable across runs — persisted memory is
|
|
701
|
+
// keyed by that id, so it must not change between invocations.
|
|
702
|
+
return parseSoul(raw, { seed: resolvedPath });
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
throw new Error(`Could not parse soul file ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
445
708
|
export const resolveRuntimeConfig = async (command, env = {}) => {
|
|
446
709
|
const processEnv = readProcessEnv(env);
|
|
447
|
-
const
|
|
448
|
-
const fileConfig =
|
|
710
|
+
const configLocation = await resolveConfigLocation(command, env);
|
|
711
|
+
const fileConfig = configLocation ? await loadConfigFile(configLocation.path) : {};
|
|
712
|
+
const soul = await resolveSoul(command, env, fileConfig);
|
|
713
|
+
// Explicit flags and env vars outrank the soul's own provider/model hints,
|
|
714
|
+
// which outrank the config file's defaults.
|
|
449
715
|
const provider = command.provider
|
|
450
716
|
?? readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
|
|
451
717
|
?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
|
|
718
|
+
?? readNonEmptyString(soul?.provider, (value) => parseProviderName(value, 'soul file'))
|
|
452
719
|
?? fileConfig.provider
|
|
453
720
|
?? 'demo';
|
|
454
721
|
if (provider === 'demo') {
|
|
455
|
-
return { provider: 'demo' };
|
|
722
|
+
return { provider: 'demo', ...(soul ? { soul } : {}) };
|
|
456
723
|
}
|
|
724
|
+
// A config file's model/baseUrl/apiKeyEnv were written for the provider
|
|
725
|
+
// named in that file. When a flag, env var, or soul selects a different
|
|
726
|
+
// provider, those values would point at the wrong API (e.g. an OpenAI
|
|
727
|
+
// base URL handed to the Anthropic SDK), so they are ignored. A file with
|
|
728
|
+
// no provider key predates the anthropic option, so its settings are
|
|
729
|
+
// treated as openai-specific.
|
|
730
|
+
const fileConfigApplies = (fileConfig.provider ?? 'openai') === provider;
|
|
731
|
+
// A soul's model was chosen for the soul's own provider. If a flag or env
|
|
732
|
+
// var overrides that provider, the model hint would target the wrong API
|
|
733
|
+
// (e.g. a Claude model sent to OpenAI), so it only applies when the soul
|
|
734
|
+
// names no provider or names the selected one.
|
|
735
|
+
const soulModelApplies = soul?.provider === undefined || soul.provider === provider;
|
|
457
736
|
const model = command.model
|
|
458
737
|
?? readNonEmptyString(processEnv.STRATUS_MODEL)
|
|
459
738
|
?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
|
|
460
|
-
??
|
|
461
|
-
??
|
|
462
|
-
|
|
463
|
-
?? readNonEmptyString(processEnv.STRATUS_BASE_URL)
|
|
464
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL)
|
|
465
|
-
?? fileConfig.baseUrl
|
|
466
|
-
?? DEFAULT_OPENAI_BASE_URL;
|
|
739
|
+
?? (soulModelApplies ? readNonEmptyString(soul?.model) : undefined)
|
|
740
|
+
?? (fileConfigApplies ? fileConfig.model : undefined)
|
|
741
|
+
?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_MODEL : DEFAULT_OPENAI_MODEL);
|
|
467
742
|
const apiKeyEnvName = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
|
|
468
743
|
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
|
|
469
|
-
?? fileConfig.apiKeyEnv
|
|
470
|
-
?? 'OPENAI_API_KEY';
|
|
471
|
-
const
|
|
744
|
+
?? (fileConfigApplies ? fileConfig.apiKeyEnv : undefined)
|
|
745
|
+
?? (provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
|
|
746
|
+
const credentials = await loadCredentials(env);
|
|
747
|
+
// A custom endpoint chosen by an auto-discovered project config is not a
|
|
748
|
+
// place the stored sign-in ever gets sent — a cloned repository could
|
|
749
|
+
// point it anywhere. Flags and env vars are the user's own choice, and
|
|
750
|
+
// the provider's default endpoint is harmless.
|
|
751
|
+
const defaultEndpointFor = (target) => target === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL;
|
|
752
|
+
const untrustedCustomBaseUrl = configLocation?.trusted === false
|
|
753
|
+
&& command.baseUrl === undefined
|
|
754
|
+
&& readNonEmptyString(processEnv.STRATUS_BASE_URL) === undefined
|
|
755
|
+
&& readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL) === undefined
|
|
756
|
+
&& fileConfigApplies
|
|
757
|
+
&& fileConfig.baseUrl !== undefined
|
|
758
|
+
&& fileConfig.baseUrl.replace(/\/+$/, '') !== defaultEndpointFor(String(provider));
|
|
759
|
+
// Env vars outrank the stored sign-in from `stratus setup`.
|
|
760
|
+
const envApiKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
472
761
|
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
|
|
473
762
|
?? readNonEmptyString(processEnv[String(apiKeyEnvName)]);
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
763
|
+
const candidateCredential = credentials[provider];
|
|
764
|
+
// A bound credential ignores config URLs entirely, so an untrusted
|
|
765
|
+
// project URL cannot redirect it — only unbound stored keys are blocked.
|
|
766
|
+
const credentialIsBound = candidateCredential?.type === 'api_key' && candidateCredential.baseUrl !== undefined;
|
|
767
|
+
const storedCredential = envApiKey || (untrustedCustomBaseUrl && !credentialIsBound)
|
|
768
|
+
? undefined
|
|
769
|
+
: candidateCredential;
|
|
770
|
+
const apiKey = envApiKey
|
|
771
|
+
?? (storedCredential?.type === 'api_key' ? storedCredential.value : undefined);
|
|
772
|
+
const authToken = provider === 'anthropic' && storedCredential?.type === 'oauth_token'
|
|
773
|
+
? storedCredential.value
|
|
774
|
+
: undefined;
|
|
775
|
+
// A stored key bound to an endpoint is used ONLY with that endpoint — a
|
|
776
|
+
// config file can never redirect it, not even to the official default
|
|
777
|
+
// URL (a project config could otherwise reroute a local-service key to
|
|
778
|
+
// the official API). An explicit flag or env URL that disagrees refuses
|
|
779
|
+
// the stored key instead of leaking it. This applies to both providers.
|
|
780
|
+
const boundBaseUrl = !envApiKey && storedCredential?.type === 'api_key'
|
|
781
|
+
? storedCredential.baseUrl
|
|
782
|
+
: undefined;
|
|
783
|
+
const explicitBaseUrl = command.baseUrl
|
|
784
|
+
?? readNonEmptyString(processEnv.STRATUS_BASE_URL)
|
|
785
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL);
|
|
786
|
+
if (boundBaseUrl && explicitBaseUrl
|
|
787
|
+
&& String(explicitBaseUrl).replace(/\/+$/, '') !== boundBaseUrl.replace(/\/+$/, '')) {
|
|
788
|
+
throw new Error(`Your saved ${provider} sign-in is bound to ${boundBaseUrl} and is not sent to ${explicitBaseUrl}. Set ${apiKeyEnvName} or STRATUS_API_KEY to use that endpoint.`);
|
|
789
|
+
}
|
|
790
|
+
const baseUrl = boundBaseUrl
|
|
791
|
+
?? explicitBaseUrl
|
|
792
|
+
?? (fileConfigApplies ? fileConfig.baseUrl : undefined)
|
|
793
|
+
// The Anthropic SDK knows its own endpoint; only openai needs a default.
|
|
794
|
+
?? (provider === 'anthropic' ? undefined : DEFAULT_OPENAI_BASE_URL);
|
|
795
|
+
if (!apiKey && !authToken) {
|
|
796
|
+
if (untrustedCustomBaseUrl && credentials[provider]) {
|
|
797
|
+
throw new Error(`The project config at ${configLocation?.path} sets a custom base URL (${fileConfig.baseUrl}), so your saved sign-in is not sent to it. Set ${apiKeyEnvName} or STRATUS_API_KEY to use this endpoint, or run with --config to trust the file explicitly.`);
|
|
798
|
+
}
|
|
799
|
+
throw new Error(`Missing API key for provider=${provider}. Run \`stratus setup\` to sign in, or set STRATUS_API_KEY or ${apiKeyEnvName}.`);
|
|
800
|
+
}
|
|
801
|
+
const resolved = provider === 'anthropic'
|
|
802
|
+
? {
|
|
803
|
+
provider: 'anthropic',
|
|
804
|
+
model: String(model),
|
|
805
|
+
...(baseUrl ? { baseUrl: String(baseUrl) } : {}),
|
|
806
|
+
...(apiKey ? { apiKey: String(apiKey) } : {}),
|
|
807
|
+
...(authToken ? { authToken } : {}),
|
|
808
|
+
}
|
|
809
|
+
: {
|
|
810
|
+
provider: 'openai',
|
|
811
|
+
model: String(model),
|
|
812
|
+
baseUrl: String(baseUrl),
|
|
813
|
+
apiKey: String(apiKey),
|
|
814
|
+
};
|
|
483
815
|
const systemPrompt = readNonEmptyString(processEnv.STRATUS_SYSTEM_PROMPT)
|
|
484
816
|
?? readNonEmptyString(processEnv.STRATUSCLAW_SYSTEM_PROMPT)
|
|
485
817
|
?? fileConfig.systemPrompt;
|
|
@@ -489,12 +821,116 @@ export const resolveRuntimeConfig = async (command, env = {}) => {
|
|
|
489
821
|
if (env.fetch) {
|
|
490
822
|
resolved.fetch = env.fetch;
|
|
491
823
|
}
|
|
824
|
+
if (soul) {
|
|
825
|
+
resolved.soul = soul;
|
|
826
|
+
}
|
|
827
|
+
// A configured fallback model kicks in when the default model errors
|
|
828
|
+
// mid-run. It needs its own working sign-in; without one the fallback is
|
|
829
|
+
// quietly skipped rather than failing the run it exists to rescue.
|
|
830
|
+
// An implicit fallback (no fallbackProvider key) was written for the
|
|
831
|
+
// config's own provider — when a flag, env var, or soul overrides that
|
|
832
|
+
// provider, the fallback model would target the wrong API, so it is
|
|
833
|
+
// ignored. An explicit fallbackProvider stays valid regardless.
|
|
834
|
+
if (fileConfig.fallbackModel && (fileConfig.fallbackProvider !== undefined || fileConfigApplies)) {
|
|
835
|
+
const fallbackProvider = fileConfig.fallbackProvider ?? provider;
|
|
836
|
+
if (fallbackProvider !== 'demo') {
|
|
837
|
+
// Same precedence as the primary sign-in: environment keys outrank
|
|
838
|
+
// the stored credential. And the same endpoint rule: an untrusted
|
|
839
|
+
// project config's custom fallback URL never receives a stored key —
|
|
840
|
+
// including the primary's stored key when both share a provider.
|
|
841
|
+
// Only env-supplied keys follow such a URL.
|
|
842
|
+
const fallbackUntrustedUrl = fallbackProvider === 'openai'
|
|
843
|
+
&& configLocation?.trusted === false
|
|
844
|
+
&& fileConfig.fallbackBaseUrl !== undefined
|
|
845
|
+
&& fileConfig.fallbackBaseUrl.replace(/\/+$/, '') !== DEFAULT_OPENAI_BASE_URL;
|
|
846
|
+
const fallbackEnvKey = readNonEmptyString(processEnv[fallbackProvider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY']);
|
|
847
|
+
const fallbackCredential = fallbackEnvKey || fallbackUntrustedUrl ? undefined : credentials[fallbackProvider];
|
|
848
|
+
const primaryReusable = fallbackProvider === provider
|
|
849
|
+
&& (envApiKey !== undefined || !fallbackUntrustedUrl);
|
|
850
|
+
const fallbackApiKey = (primaryReusable ? apiKey : undefined)
|
|
851
|
+
?? fallbackEnvKey
|
|
852
|
+
?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.value : undefined);
|
|
853
|
+
const fallbackAuthToken = fallbackProvider !== provider && fallbackProvider === 'anthropic' && fallbackCredential?.type === 'oauth_token'
|
|
854
|
+
? fallbackCredential.value
|
|
855
|
+
: (primaryReusable ? authToken : undefined);
|
|
856
|
+
if (fallbackApiKey || fallbackAuthToken) {
|
|
857
|
+
// When the fallback key comes out of the credential store (its own
|
|
858
|
+
// entry, or the primary's reused stored key), its bound endpoint is
|
|
859
|
+
// authoritative — config URLs cannot redirect it.
|
|
860
|
+
const fallbackBoundUrl = fallbackCredential?.type === 'api_key'
|
|
861
|
+
? fallbackCredential.baseUrl
|
|
862
|
+
: (primaryReusable && !envApiKey ? boundBaseUrl : undefined);
|
|
863
|
+
// An anthropic fallback on the same provider keeps the primary's
|
|
864
|
+
// configured endpoint — retrying the same credential against the
|
|
865
|
+
// official endpoint instead of the configured service would leak
|
|
866
|
+
// it and likely fail.
|
|
867
|
+
const fallbackAnthropicBaseUrl = fallbackProvider === 'anthropic'
|
|
868
|
+
? (fallbackProvider === provider && baseUrl ? String(baseUrl) : undefined)
|
|
869
|
+
?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.baseUrl : undefined)
|
|
870
|
+
: undefined;
|
|
871
|
+
resolved.fallback = {
|
|
872
|
+
provider: fallbackProvider,
|
|
873
|
+
model: fileConfig.fallbackModel,
|
|
874
|
+
...(fallbackProvider === 'openai'
|
|
875
|
+
? {
|
|
876
|
+
baseUrl: fallbackBoundUrl
|
|
877
|
+
?? fileConfig.fallbackBaseUrl
|
|
878
|
+
?? (fallbackProvider === provider ? String(baseUrl) : undefined)
|
|
879
|
+
?? DEFAULT_OPENAI_BASE_URL,
|
|
880
|
+
}
|
|
881
|
+
: (fallbackAnthropicBaseUrl ? { baseUrl: fallbackAnthropicBaseUrl } : {})),
|
|
882
|
+
...(fallbackApiKey ? { apiKey: String(fallbackApiKey) } : {}),
|
|
883
|
+
...(fallbackAuthToken ? { authToken: fallbackAuthToken } : {}),
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
492
888
|
return resolved;
|
|
493
889
|
};
|
|
494
|
-
|
|
890
|
+
// Wraps the fallback runtime as a provider: the primary model serves every
|
|
891
|
+
// turn until it throws, then the run switches to the fallback for good.
|
|
892
|
+
const createFallbackWrappedProvider = (primary, fallback, onFallback) => {
|
|
893
|
+
let usingFallback = false;
|
|
894
|
+
return {
|
|
895
|
+
name: primary.name,
|
|
896
|
+
async generate(request) {
|
|
897
|
+
if (!usingFallback) {
|
|
898
|
+
try {
|
|
899
|
+
return await primary.generate(request);
|
|
900
|
+
}
|
|
901
|
+
catch (error) {
|
|
902
|
+
usingFallback = true;
|
|
903
|
+
onFallback(error);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return fallback.generate(request);
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
};
|
|
910
|
+
const createRuntimeProvider = (config, onFallback) => {
|
|
495
911
|
if (config.provider === 'demo') {
|
|
496
912
|
return createDemoProvider();
|
|
497
913
|
}
|
|
914
|
+
if (config.fallback) {
|
|
915
|
+
const { fallback, ...primaryConfig } = config;
|
|
916
|
+
const primary = createRuntimeProvider(primaryConfig);
|
|
917
|
+
const fallbackProvider = createRuntimeProvider({
|
|
918
|
+
...fallback,
|
|
919
|
+
...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
|
|
920
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
921
|
+
});
|
|
922
|
+
return createFallbackWrappedProvider(primary, fallbackProvider, onFallback ?? (() => { }));
|
|
923
|
+
}
|
|
924
|
+
if (config.provider === 'anthropic') {
|
|
925
|
+
return createAnthropicProvider({
|
|
926
|
+
model: config.model,
|
|
927
|
+
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
|
928
|
+
...(config.authToken ? { authToken: config.authToken } : {}),
|
|
929
|
+
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
|
930
|
+
...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
|
|
931
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
932
|
+
});
|
|
933
|
+
}
|
|
498
934
|
return createOpenAICompatibleProvider({
|
|
499
935
|
name: 'openai',
|
|
500
936
|
model: config.model,
|
|
@@ -536,8 +972,12 @@ const createApprovalPolicy = (mode, streams, env) => {
|
|
|
536
972
|
};
|
|
537
973
|
};
|
|
538
974
|
export const runSingleLoop = async (prompt, streams, options) => {
|
|
975
|
+
const runEnv = options.env ?? {};
|
|
976
|
+
await migrateLegacyMemory(runEnv);
|
|
977
|
+
const memory = createFileMemoryStore(memoryFilePath(runEnv));
|
|
539
978
|
const tools = new ToolRegistry();
|
|
540
979
|
tools.register(createDemoTool());
|
|
980
|
+
tools.register(createRememberTool(memory));
|
|
541
981
|
const bus = new EventBus({
|
|
542
982
|
onError: (error) => {
|
|
543
983
|
writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -551,33 +991,45 @@ export const runSingleLoop = async (prompt, streams, options) => {
|
|
|
551
991
|
}
|
|
552
992
|
});
|
|
553
993
|
}
|
|
554
|
-
const runtimeProvider = createRuntimeProvider(options.runtime)
|
|
994
|
+
const runtimeProvider = createRuntimeProvider(options.runtime, (error) => {
|
|
995
|
+
const fallback = options.runtime.provider === 'demo' ? undefined : options.runtime.fallback;
|
|
996
|
+
writeLine(streams.stderr, `Warning: the default model failed (${error instanceof Error ? error.message : String(error)}); falling back to ${fallback?.model ?? 'the fallback model'}.`);
|
|
997
|
+
});
|
|
555
998
|
const runner = new AgentRunner({
|
|
556
999
|
provider: runtimeProvider,
|
|
557
1000
|
tools,
|
|
558
1001
|
executor: createLocalCommandExecutor(),
|
|
559
1002
|
approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}),
|
|
560
1003
|
bus,
|
|
1004
|
+
memory,
|
|
561
1005
|
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
562
1006
|
});
|
|
563
1007
|
await runner.initialize();
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1008
|
+
// A soul is a full identity — it replaces the built-in per-provider agent.
|
|
1009
|
+
const agent = options.runtime.soul?.agent
|
|
1010
|
+
?? (options.runtime.provider === 'demo'
|
|
1011
|
+
? {
|
|
1012
|
+
id: 'demo-agent',
|
|
1013
|
+
name: 'Demo Agent',
|
|
1014
|
+
instructions: 'Keep the loop tiny and readable.',
|
|
1015
|
+
}
|
|
1016
|
+
: options.runtime.provider === 'anthropic'
|
|
1017
|
+
? {
|
|
1018
|
+
id: 'anthropic-agent',
|
|
1019
|
+
name: 'Claude Agent',
|
|
1020
|
+
instructions: 'Respond clearly and directly to the user request.',
|
|
1021
|
+
}
|
|
1022
|
+
: {
|
|
1023
|
+
id: 'openai-agent',
|
|
1024
|
+
name: 'OpenAI Agent',
|
|
1025
|
+
instructions: 'Respond clearly and directly to the user request.',
|
|
1026
|
+
});
|
|
575
1027
|
const metadata = options.runtime.provider === 'demo'
|
|
576
1028
|
? { provider: 'demo' }
|
|
577
1029
|
: {
|
|
578
|
-
provider:
|
|
1030
|
+
provider: options.runtime.provider,
|
|
579
1031
|
model: options.runtime.model,
|
|
580
|
-
baseUrl: options.runtime.baseUrl,
|
|
1032
|
+
...(options.runtime.provider === 'openai' ? { baseUrl: options.runtime.baseUrl } : {}),
|
|
581
1033
|
};
|
|
582
1034
|
return runner.run({
|
|
583
1035
|
sessionId: randomUUID(),
|
|
@@ -609,10 +1061,12 @@ export const printSessionSummary = (session, streams) => {
|
|
|
609
1061
|
}
|
|
610
1062
|
};
|
|
611
1063
|
const formatRuntimeBanner = (runtime) => {
|
|
1064
|
+
const soulSuffix = runtime.soul ? ` as ${runtime.soul.agent.name}` : '';
|
|
612
1065
|
if (runtime.provider === 'demo') {
|
|
613
|
-
return
|
|
1066
|
+
return `Starting Stratus Agent local loop with provider=demo${soulSuffix}`;
|
|
614
1067
|
}
|
|
615
|
-
|
|
1068
|
+
const fallbackSuffix = runtime.fallback ? ` fallback=${runtime.fallback.model}` : '';
|
|
1069
|
+
return `Starting Stratus Agent local loop with provider=${runtime.provider} model=${runtime.model}${fallbackSuffix}${soulSuffix}`;
|
|
616
1070
|
};
|
|
617
1071
|
const escapeHtml = (value) => value
|
|
618
1072
|
.replaceAll('&', '&')
|
|
@@ -754,7 +1208,7 @@ export const startDashboardServer = async (options) => {
|
|
|
754
1208
|
sendJson(response, 200, {
|
|
755
1209
|
ok: true,
|
|
756
1210
|
service: 'stratus-dashboard',
|
|
757
|
-
version: '0.1
|
|
1211
|
+
version: '0.2.1',
|
|
758
1212
|
now: new Date().toISOString(),
|
|
759
1213
|
});
|
|
760
1214
|
return;
|
|
@@ -811,6 +1265,189 @@ export const startDashboardServer = async (options) => {
|
|
|
811
1265
|
// the safe character set gets single-quoted.
|
|
812
1266
|
const quoteShellArg = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
813
1267
|
const createSetupPrompter = (streams, env) => {
|
|
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.
|
|
1270
|
+
const interactive = env.setupInput === undefined && process.stdin.isTTY === true;
|
|
1271
|
+
if (interactive) {
|
|
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
|
+
},
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
814
1451
|
const input = env.setupInput ?? process.stdin;
|
|
815
1452
|
const readline = createInterface({ input, terminal: false });
|
|
816
1453
|
const pendingLines = [];
|
|
@@ -821,179 +1458,947 @@ const createSetupPrompter = (streams, env) => {
|
|
|
821
1458
|
readline.once('close', () => {
|
|
822
1459
|
closed = true;
|
|
823
1460
|
});
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1461
|
+
const nextLine = async () => {
|
|
1462
|
+
while (pendingLines.length === 0) {
|
|
1463
|
+
if (closed) {
|
|
1464
|
+
return '';
|
|
1465
|
+
}
|
|
1466
|
+
await new Promise((resolve) => {
|
|
1467
|
+
readline.once('line', () => resolve());
|
|
1468
|
+
readline.once('close', () => resolve());
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
return (pendingLines.shift() ?? '').trim();
|
|
1472
|
+
};
|
|
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' };
|
|
830
1488
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1489
|
+
return { kind: 'index', index: defaultIndex };
|
|
1490
|
+
}
|
|
1491
|
+
if (/^\d+$/.test(line)) {
|
|
1492
|
+
const picked = Number(line) - 1;
|
|
1493
|
+
if (picked >= 0 && picked < options.length) {
|
|
1494
|
+
return { kind: 'index', index: picked };
|
|
1495
|
+
}
|
|
1496
|
+
writeLine(streams.stdout, `Pick a number between 1 and ${options.length}.`);
|
|
1497
|
+
continue;
|
|
835
1498
|
}
|
|
836
|
-
|
|
837
|
-
|
|
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 };
|
|
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();
|
|
838
1516
|
},
|
|
839
|
-
|
|
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);
|
|
1527
|
+
},
|
|
1528
|
+
isClosed: () => closed && pendingLines.length === 0,
|
|
1529
|
+
close: () => {
|
|
840
1530
|
readline.close();
|
|
841
1531
|
},
|
|
842
1532
|
};
|
|
843
1533
|
};
|
|
844
|
-
|
|
1534
|
+
// Live check that a pasted key actually works, so the user finds out inside
|
|
1535
|
+
// setup instead of on their first run.
|
|
1536
|
+
const verifyProviderKey = async (provider, key, baseUrl, fetchImpl) => {
|
|
1537
|
+
if (typeof fetchImpl !== 'function') {
|
|
1538
|
+
return { status: 'unreachable', detail: 'fetch is unavailable' };
|
|
1539
|
+
}
|
|
1540
|
+
const root = (baseUrl ?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL)).replace(/\/+$/, '');
|
|
1541
|
+
const url = provider === 'anthropic' ? `${root}/v1/models` : `${root}/models`;
|
|
1542
|
+
const headers = provider === 'anthropic'
|
|
1543
|
+
? { 'x-api-key': key, 'anthropic-version': '2023-06-01' }
|
|
1544
|
+
: { authorization: `Bearer ${key}` };
|
|
845
1545
|
try {
|
|
846
|
-
await
|
|
847
|
-
|
|
1546
|
+
const response = await fetchImpl(url, { headers });
|
|
1547
|
+
if (response.ok) {
|
|
1548
|
+
return { status: 'ok' };
|
|
1549
|
+
}
|
|
1550
|
+
// Only an explicit auth failure condemns the key. Compatible endpoints
|
|
1551
|
+
// (local models, proxies) often lack GET /models entirely — a 404/405
|
|
1552
|
+
// there says nothing about the key, so it stays saveable.
|
|
1553
|
+
if (response.status === 401 || response.status === 403) {
|
|
1554
|
+
return { status: 'rejected', detail: `HTTP ${response.status}` };
|
|
1555
|
+
}
|
|
1556
|
+
return { status: 'unreachable', detail: `the endpoint did not support a key check (HTTP ${response.status})` };
|
|
848
1557
|
}
|
|
849
1558
|
catch (error) {
|
|
850
|
-
|
|
851
|
-
return false;
|
|
852
|
-
}
|
|
853
|
-
throw error;
|
|
1559
|
+
return { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) };
|
|
854
1560
|
}
|
|
855
1561
|
};
|
|
1562
|
+
const DEFAULT_SOUL_STARTER = [
|
|
1563
|
+
'You are a helpful, warm generalist. Answer first, explain second, and',
|
|
1564
|
+
'keep replies short unless the question genuinely needs depth. Use',
|
|
1565
|
+
'memory.remember for durable facts about the people you work with.',
|
|
1566
|
+
].join('\n');
|
|
1567
|
+
// Shown when live model listing is unavailable (e.g. subscription tokens
|
|
1568
|
+
// cannot call the models endpoint, or the machine is offline).
|
|
1569
|
+
// Model ids that cannot serve /chat/completions and must not become the
|
|
1570
|
+
// default: embeddings, audio, images, moderation, and legacy completions.
|
|
1571
|
+
const NON_CHAT_MODEL_PATTERN = /embed|whisper|tts|audio|dall-e|image|moderation|realtime|transcribe|davinci|babbage|curie|(^|[-_])ada([-_]|$)/i;
|
|
1572
|
+
const KNOWN_CLAUDE_MODELS = [
|
|
1573
|
+
'claude-opus-5',
|
|
1574
|
+
'claude-sonnet-5',
|
|
1575
|
+
'claude-haiku-4-5',
|
|
1576
|
+
'claude-opus-4-6',
|
|
1577
|
+
'claude-sonnet-4-6',
|
|
1578
|
+
];
|
|
856
1579
|
export const runSetup = async (command, streams, env = {}) => {
|
|
857
1580
|
const cwd = readWorkingDirectory(env);
|
|
858
1581
|
const processEnv = readProcessEnv(env);
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
// is
|
|
1582
|
+
// Config target: --config, then STRATUS_CONFIG / STRATUSCLAW_CONFIG, then
|
|
1583
|
+
// the global ~/.stratus/config.json — the file `stratus run` falls back to
|
|
1584
|
+
// from any directory, which is what makes setup a one-time step.
|
|
862
1585
|
const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG)
|
|
863
1586
|
? 'STRATUS_CONFIG'
|
|
864
1587
|
: readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
|
|
865
1588
|
? 'STRATUSCLAW_CONFIG'
|
|
866
1589
|
: undefined;
|
|
867
1590
|
const envConfigPath = envConfigVar ? String(processEnv[envConfigVar]).trim() : undefined;
|
|
868
|
-
const
|
|
869
|
-
|
|
1591
|
+
const configPath = command.configPath
|
|
1592
|
+
? path.resolve(cwd, command.configPath)
|
|
1593
|
+
: envConfigPath
|
|
1594
|
+
? path.resolve(cwd, envConfigPath)
|
|
1595
|
+
: globalConfigPath(env);
|
|
870
1596
|
// A path passed via --config is not auto-discovered by `stratus run`, so
|
|
871
|
-
// suggested commands must carry it explicitly.
|
|
872
|
-
// flag because `run` reads the same variable.
|
|
1597
|
+
// suggested commands must carry it explicitly.
|
|
873
1598
|
const runConfigFlag = command.configPath ? ` --config ${quoteShellArg(command.configPath)}` : '';
|
|
874
|
-
|
|
1599
|
+
// Seed from what is already configured, so re-running setup edits instead
|
|
1600
|
+
// of clobbering.
|
|
1601
|
+
let existing = {};
|
|
875
1602
|
try {
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
if (
|
|
880
|
-
writeLine(streams.
|
|
1603
|
+
existing = await loadConfigFile(configPath);
|
|
1604
|
+
}
|
|
1605
|
+
catch (error) {
|
|
1606
|
+
if (error.code !== 'ENOENT') {
|
|
1607
|
+
writeLine(streams.stderr, `Warning: could not read ${configPath} (${error instanceof Error ? error.message : String(error)}); starting fresh.`);
|
|
881
1608
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1609
|
+
}
|
|
1610
|
+
const state = {
|
|
1611
|
+
provider: existing.provider ?? 'anthropic',
|
|
1612
|
+
...(existing.model ? { model: existing.model } : {}),
|
|
1613
|
+
...(existing.baseUrl ? { baseUrl: existing.baseUrl } : {}),
|
|
1614
|
+
...(existing.apiKeyEnv ? { apiKeyEnv: existing.apiKeyEnv } : {}),
|
|
1615
|
+
...(existing.systemPrompt ? { systemPrompt: existing.systemPrompt } : {}),
|
|
1616
|
+
...(existing.soul ? { soulPath: existing.soul } : {}),
|
|
1617
|
+
...(existing.fallbackModel ? { fallbackModel: existing.fallbackModel } : {}),
|
|
1618
|
+
// Pin an implicit fallback provider now, so a later default-provider
|
|
1619
|
+
// switch cannot silently change what the fallback means.
|
|
1620
|
+
...(existing.fallbackModel || existing.fallbackProvider
|
|
1621
|
+
? { fallbackProvider: existing.fallbackProvider ?? existing.provider ?? 'anthropic' }
|
|
1622
|
+
: {}),
|
|
1623
|
+
...(existing.fallbackBaseUrl ? { fallbackBaseUrl: existing.fallbackBaseUrl } : {}),
|
|
1624
|
+
credentials: await loadCredentials(env),
|
|
1625
|
+
credentialsDirty: false,
|
|
1626
|
+
};
|
|
1627
|
+
const prompter = createSetupPrompter(streams, env);
|
|
1628
|
+
const defaultModelFor = (provider) => provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL;
|
|
1629
|
+
const defaultKeyEnvFor = (provider) => provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
|
1630
|
+
const credentialLabel = (credential) => credential.type === 'oauth_token' ? 'Claude subscription' : 'API key';
|
|
1631
|
+
const providerSignInStatus = (provider) => {
|
|
1632
|
+
const credential = state.credentials[provider];
|
|
1633
|
+
if (credential) {
|
|
1634
|
+
return `signed in (${credentialLabel(credential)})`;
|
|
1635
|
+
}
|
|
1636
|
+
if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
|
|
1637
|
+
return `using ${defaultKeyEnvFor(provider)} from your environment`;
|
|
1638
|
+
}
|
|
1639
|
+
return 'not signed in';
|
|
1640
|
+
};
|
|
1641
|
+
const providersSummary = () => {
|
|
1642
|
+
const parts = [];
|
|
1643
|
+
for (const provider of ['anthropic', 'openai']) {
|
|
1644
|
+
const credential = state.credentials[provider];
|
|
1645
|
+
if (credential) {
|
|
1646
|
+
parts.push(`${provider} (${credentialLabel(credential)})`);
|
|
1647
|
+
}
|
|
1648
|
+
else if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
|
|
1649
|
+
parts.push(`${provider} (env key)`);
|
|
888
1650
|
}
|
|
889
|
-
writeLine(streams.stdout);
|
|
890
1651
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1652
|
+
if (parts.length === 0) {
|
|
1653
|
+
return state.provider === 'demo' ? 'demo — offline, no account' : 'none signed in yet';
|
|
1654
|
+
}
|
|
1655
|
+
if (state.provider === 'demo') {
|
|
1656
|
+
parts.push('default: demo');
|
|
1657
|
+
}
|
|
1658
|
+
return parts.join(' · ');
|
|
1659
|
+
};
|
|
1660
|
+
const modelsSummary = () => {
|
|
1661
|
+
if (state.provider === 'demo') {
|
|
1662
|
+
return 'demo (no model)';
|
|
1663
|
+
}
|
|
1664
|
+
const base = `default ${state.model ?? `${defaultModelFor(state.provider)} (default)`}`;
|
|
1665
|
+
return state.fallbackModel ? `${base} · fallback ${state.fallbackModel}` : `${base} · no fallback`;
|
|
1666
|
+
};
|
|
1667
|
+
const signInSummary = () => {
|
|
1668
|
+
if (state.provider === 'demo') {
|
|
1669
|
+
return 'no account needed';
|
|
1670
|
+
}
|
|
1671
|
+
const credential = state.credentials[state.provider];
|
|
1672
|
+
if (credential) {
|
|
1673
|
+
return credential.type === 'oauth_token'
|
|
1674
|
+
? 'signed in with your Claude subscription'
|
|
1675
|
+
: 'signed in with an API key';
|
|
1676
|
+
}
|
|
1677
|
+
const keyEnv = state.apiKeyEnv ?? defaultKeyEnvFor(state.provider);
|
|
1678
|
+
if (readNonEmptyString(processEnv.STRATUS_API_KEY) ?? readNonEmptyString(processEnv[keyEnv])) {
|
|
1679
|
+
return `using ${readNonEmptyString(processEnv.STRATUS_API_KEY) ? 'STRATUS_API_KEY' : keyEnv} from your environment`;
|
|
1680
|
+
}
|
|
1681
|
+
return 'not signed in yet';
|
|
1682
|
+
};
|
|
1683
|
+
const agentSummary = () => {
|
|
1684
|
+
if (!state.soulPath) {
|
|
1685
|
+
return 'none — every run uses the built-in default agent';
|
|
1686
|
+
}
|
|
1687
|
+
return state.soulPath;
|
|
1688
|
+
};
|
|
1689
|
+
const storeCredential = (provider, credential) => {
|
|
1690
|
+
state.credentials[provider] = credential;
|
|
1691
|
+
state.credentialsDirty = true;
|
|
1692
|
+
};
|
|
1693
|
+
const signInAnthropic = async () => {
|
|
1694
|
+
const signedIn = state.credentials.anthropic !== undefined;
|
|
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) {
|
|
1702
|
+
delete state.credentials.anthropic;
|
|
1703
|
+
state.credentialsDirty = true;
|
|
1704
|
+
writeLine(streams.stdout, 'Signed out of Anthropic.');
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
if (answer.kind !== 'index' || answer.index === 2) {
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (answer.index === 1) {
|
|
1711
|
+
const key = await prompter.askSecret('Paste your Anthropic API key (starts with sk-ant-, Enter to skip; input is hidden): ');
|
|
1712
|
+
if (!key) {
|
|
1713
|
+
writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
writeLine(streams.stdout, 'Checking the key against the Anthropic API…');
|
|
1717
|
+
// The key is verified against the configured endpoint and bound to
|
|
1718
|
+
// it, so a later provider switch (or an anthropic fallback) can never
|
|
1719
|
+
// send a proxy credential to the official endpoint.
|
|
1720
|
+
const verifyEndpoint = state.provider === 'anthropic' ? state.baseUrl : undefined;
|
|
1721
|
+
const binding = verifyEndpoint && verifyEndpoint.replace(/\/+$/, '') !== DEFAULT_ANTHROPIC_BASE_URL
|
|
1722
|
+
? { baseUrl: verifyEndpoint }
|
|
1723
|
+
: {};
|
|
1724
|
+
const verdict = await verifyProviderKey('anthropic', key, verifyEndpoint, env.fetch ?? globalThis.fetch);
|
|
1725
|
+
if (verdict.status === 'ok') {
|
|
1726
|
+
storeCredential('anthropic', { type: 'api_key', value: key, ...binding });
|
|
1727
|
+
writeLine(streams.stdout, '✓ Key verified — you are signed in to Anthropic.');
|
|
1728
|
+
}
|
|
1729
|
+
else if (verdict.status === 'rejected') {
|
|
1730
|
+
writeLine(streams.stdout, `✗ Anthropic rejected that key (${verdict.detail}). It was NOT saved — check console.anthropic.com and try again from this menu.`);
|
|
1731
|
+
}
|
|
1732
|
+
else {
|
|
1733
|
+
storeCredential('anthropic', { type: 'api_key', value: key, ...binding });
|
|
1734
|
+
writeLine(streams.stdout, `! Could not reach the Anthropic API to verify (${verdict.detail}). Saved the key anyway — it will be checked on your first run.`);
|
|
1735
|
+
}
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
// Default: subscription sign-in via Claude Code.
|
|
1739
|
+
writeLine(streams.stdout, 'Your Claude Pro/Max subscription covers usage made through Claude Code.');
|
|
1740
|
+
writeLine(streams.stdout, 'In another terminal on this machine, run:');
|
|
1741
|
+
writeLine(streams.stdout, ' claude setup-token');
|
|
1742
|
+
writeLine(streams.stdout, '(requires Claude Code installed and signed in to your Claude account)');
|
|
1743
|
+
const token = await prompter.askSecret('Paste the setup token it prints (starts with sk-ant-oat, Enter to skip; input is hidden): ');
|
|
1744
|
+
if (!token) {
|
|
1745
|
+
writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
storeCredential('anthropic', { type: 'oauth_token', value: token });
|
|
1749
|
+
writeLine(streams.stdout, '✓ Subscription token saved. It is verified on your first run.');
|
|
1750
|
+
};
|
|
1751
|
+
const signInOpenAI = async () => {
|
|
1752
|
+
const currentEndpoint = (state.provider === 'openai' ? state.baseUrl : undefined)
|
|
1753
|
+
?? state.credentials.openai?.baseUrl
|
|
1754
|
+
?? DEFAULT_OPENAI_BASE_URL;
|
|
1755
|
+
const baseUrlAnswer = await prompter.ask(`API base URL [${currentEndpoint}]: `);
|
|
1756
|
+
const chosenEndpoint = baseUrlAnswer || currentEndpoint;
|
|
1757
|
+
// state.baseUrl describes the DEFAULT provider's endpoint; a secondary
|
|
1758
|
+
// openai sign-in keeps its endpoint on the credential instead. The
|
|
1759
|
+
// change is committed only once a sign-in is accepted — a rejected key
|
|
1760
|
+
// must not leave a new endpoint paired with the old credential.
|
|
1761
|
+
const commitEndpoint = () => {
|
|
1762
|
+
if (state.provider === 'openai') {
|
|
1763
|
+
state.baseUrl = chosenEndpoint;
|
|
1764
|
+
}
|
|
1765
|
+
};
|
|
1766
|
+
const key = await prompter.askSecret('Paste your API key (Enter to skip; input is hidden): ');
|
|
1767
|
+
if (!key) {
|
|
1768
|
+
// Without a stored credential there is no old key the new endpoint
|
|
1769
|
+
// could be mispaired with.
|
|
1770
|
+
if (!state.credentials.openai) {
|
|
1771
|
+
commitEndpoint();
|
|
1772
|
+
}
|
|
1773
|
+
else if (chosenEndpoint !== currentEndpoint) {
|
|
1774
|
+
writeLine(streams.stdout, 'Endpoint left unchanged — paste a key for the new endpoint to switch to it.');
|
|
1775
|
+
}
|
|
1776
|
+
writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
writeLine(streams.stdout, 'Checking the key…');
|
|
1780
|
+
// The endpoint travels with the credential, so this sign-in keeps
|
|
1781
|
+
// working even when another provider is the default.
|
|
1782
|
+
const endpoint = chosenEndpoint !== DEFAULT_OPENAI_BASE_URL ? { baseUrl: chosenEndpoint } : {};
|
|
1783
|
+
const verdict = await verifyProviderKey('openai', key, chosenEndpoint, env.fetch ?? globalThis.fetch);
|
|
1784
|
+
if (verdict.status === 'ok') {
|
|
1785
|
+
storeCredential('openai', { type: 'api_key', value: key, ...endpoint });
|
|
1786
|
+
commitEndpoint();
|
|
1787
|
+
writeLine(streams.stdout, '✓ Key verified — you are signed in.');
|
|
1788
|
+
}
|
|
1789
|
+
else if (verdict.status === 'rejected') {
|
|
1790
|
+
writeLine(streams.stdout, `✗ The API rejected that key (${verdict.detail}). It was NOT saved — try again from this menu.`);
|
|
1791
|
+
if (chosenEndpoint !== currentEndpoint) {
|
|
1792
|
+
writeLine(streams.stdout, 'The endpoint was left unchanged as well.');
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
else {
|
|
1796
|
+
storeCredential('openai', { type: 'api_key', value: key, ...endpoint });
|
|
1797
|
+
commitEndpoint();
|
|
1798
|
+
writeLine(streams.stdout, `! Could not reach the API to verify (${verdict.detail}). Saved the key anyway — it will be checked on your first run.`);
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1801
|
+
// Changing the default provider invalidates settings that were chosen
|
|
1802
|
+
// for the old one: the model and apiKeyEnv are cleared (defaults take
|
|
1803
|
+
// over), while the openai base URL is kept — it belongs to the openai
|
|
1804
|
+
// sign-in and still serves openai fallbacks. A soul that pins a provider
|
|
1805
|
+
// outranks the config at run time, so that earns a warning, not a reset.
|
|
1806
|
+
const switchDefaultProvider = async (next) => {
|
|
1807
|
+
if (state.provider === next) {
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
state.provider = next;
|
|
1811
|
+
delete state.model;
|
|
1812
|
+
delete state.apiKeyEnv;
|
|
1813
|
+
// state.baseUrl is the DEFAULT provider's endpoint; the old provider's
|
|
1814
|
+
// URL must not follow the switch. An openai default reseeds from the
|
|
1815
|
+
// credential's bound endpoint.
|
|
1816
|
+
delete state.baseUrl;
|
|
1817
|
+
if (next === 'openai' && state.credentials.openai?.baseUrl) {
|
|
1818
|
+
state.baseUrl = state.credentials.openai.baseUrl;
|
|
1819
|
+
}
|
|
1820
|
+
if (state.soulPath) {
|
|
1821
|
+
try {
|
|
1822
|
+
const soul = parseSoul(await readFile(state.soulPath, 'utf8'), { seed: state.soulPath });
|
|
1823
|
+
if (soul.provider && soul.provider !== next) {
|
|
1824
|
+
writeLine(streams.stdout, `Heads up: your default agent (${soul.agent.name}) pins provider ${soul.provider} in their soul, which outranks this choice at run time. Edit ${state.soulPath} or clear the agent (menu 3).`);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
catch {
|
|
1828
|
+
// A broken soul file surfaces when it is actually used.
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
// Whether a provider could actually serve a run right now, through any
|
|
1833
|
+
// credential source a real run would consider: the stored sign-in, the
|
|
1834
|
+
// generic STRATUS_API_KEY, a configured apiKeyEnv, or the provider's own
|
|
1835
|
+
// env var.
|
|
1836
|
+
const providerUsable = (provider) => {
|
|
1837
|
+
if (provider === 'demo') {
|
|
1838
|
+
return true;
|
|
1839
|
+
}
|
|
1840
|
+
const keyEnvSelector = provider === state.provider
|
|
1841
|
+
? readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
|
|
1842
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
|
|
1843
|
+
?? state.apiKeyEnv
|
|
1844
|
+
: undefined;
|
|
1845
|
+
return state.credentials[provider] !== undefined
|
|
1846
|
+
|| readNonEmptyString(processEnv.STRATUS_API_KEY) !== undefined
|
|
1847
|
+
|| (keyEnvSelector ? readNonEmptyString(processEnv[String(keyEnvSelector)]) !== undefined : false)
|
|
1848
|
+
|| readNonEmptyString(processEnv[defaultKeyEnvFor(provider)]) !== undefined;
|
|
1849
|
+
};
|
|
1850
|
+
// Signing in makes that provider the default only when the current
|
|
1851
|
+
// default cannot actually run (demo, or a provider with no usable key).
|
|
1852
|
+
const maybeSwitchDefault = async (provider) => {
|
|
1853
|
+
if (state.provider === 'demo' || (state.provider !== provider && !providerUsable(state.provider))) {
|
|
1854
|
+
await switchDefaultProvider(provider);
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
const chooseProviders = async () => {
|
|
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) {
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
if (answer.index === 2) {
|
|
1868
|
+
await switchDefaultProvider('demo');
|
|
1869
|
+
writeLine(streams.stdout, 'Demo selected — no sign-in needed. Mention "echo" or "tool" in a prompt to see tool calls.');
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (answer.index === 1) {
|
|
1873
|
+
await signInOpenAI();
|
|
1874
|
+
if (state.credentials.openai) {
|
|
1875
|
+
await maybeSwitchDefault('openai');
|
|
1876
|
+
}
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
await signInAnthropic();
|
|
1880
|
+
if (state.credentials.anthropic) {
|
|
1881
|
+
await maybeSwitchDefault('anthropic');
|
|
1882
|
+
}
|
|
1883
|
+
};
|
|
1884
|
+
// Every model the current sign-ins can actually reach, fetched live where
|
|
1885
|
+
// possible. Subscription tokens cannot call the models endpoint, so those
|
|
1886
|
+
// fall back to the known Claude lineup.
|
|
1887
|
+
const collectAvailableModels = async () => {
|
|
1888
|
+
const fetchImpl = env.fetch ?? globalThis.fetch;
|
|
1889
|
+
const models = [];
|
|
1890
|
+
for (const provider of ['anthropic', 'openai']) {
|
|
1891
|
+
// Discovery uses the credential a real run would use. STRATUS_API_KEY
|
|
1892
|
+
// and a configured apiKeyEnv authenticate the DEFAULT provider only —
|
|
1893
|
+
// a secondary provider relies on its own env var or stored sign-in,
|
|
1894
|
+
// never the default provider's secret.
|
|
1895
|
+
const envKey = (provider === state.provider
|
|
1896
|
+
? readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
1897
|
+
?? (state.apiKeyEnv ? readNonEmptyString(processEnv[state.apiKeyEnv]) : undefined)
|
|
1898
|
+
: undefined)
|
|
1899
|
+
?? readNonEmptyString(processEnv[defaultKeyEnvFor(provider)]);
|
|
1900
|
+
const credential = envKey ? undefined : state.credentials[provider];
|
|
1901
|
+
const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
|
|
1902
|
+
if (!apiKey && !credential) {
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
if (provider === 'anthropic') {
|
|
1906
|
+
if (!apiKey || typeof fetchImpl !== 'function') {
|
|
1907
|
+
// Subscription tokens cannot call the models endpoint.
|
|
1908
|
+
models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
// The same endpoint a real run uses: the stored key's bound URL is
|
|
1912
|
+
// authoritative, then a configured anthropic base URL (a proxy) —
|
|
1913
|
+
// never the official endpoint by accident.
|
|
1914
|
+
const anthropicRoot = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
|
|
1915
|
+
?? (state.provider === 'anthropic' ? state.baseUrl : undefined)
|
|
1916
|
+
?? DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/, '');
|
|
1917
|
+
try {
|
|
1918
|
+
const response = await fetchImpl(`${anthropicRoot}/v1/models?limit=100`, {
|
|
1919
|
+
headers: { 'x-api-key': String(apiKey), 'anthropic-version': '2023-06-01' },
|
|
1920
|
+
});
|
|
1921
|
+
const payload = await response.json();
|
|
1922
|
+
const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string');
|
|
1923
|
+
models.push(...(ids.length > 0 ? ids : KNOWN_CLAUDE_MODELS).map((id) => ({ provider, id })));
|
|
1924
|
+
}
|
|
1925
|
+
catch {
|
|
1926
|
+
models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
|
|
1927
|
+
}
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
if (!apiKey || typeof fetchImpl !== 'function') {
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
try {
|
|
1934
|
+
// A stored key's bound endpoint is authoritative, exactly as at run
|
|
1935
|
+
// time; only env-supplied keys follow the default provider's URL.
|
|
1936
|
+
const root = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
|
|
1937
|
+
?? (state.provider === 'openai' ? state.baseUrl : undefined)
|
|
1938
|
+
?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
|
|
1939
|
+
const response = await fetchImpl(`${root}/models`, {
|
|
1940
|
+
headers: { authorization: `Bearer ${String(apiKey)}` },
|
|
1941
|
+
});
|
|
1942
|
+
const payload = await response.json();
|
|
1943
|
+
const allIds = (payload.data ?? [])
|
|
1944
|
+
.map((entry) => entry.id)
|
|
1945
|
+
.filter((id) => typeof id === 'string');
|
|
1946
|
+
// Runs always call /chat/completions, so embedding, audio, image,
|
|
1947
|
+
// moderation, and legacy completion models would save a default
|
|
1948
|
+
// that cannot execute. If filtering leaves nothing (an exotic local
|
|
1949
|
+
// service), show everything rather than an empty menu.
|
|
1950
|
+
const chatIds = allIds.filter((id) => !NON_CHAT_MODEL_PATTERN.test(id));
|
|
1951
|
+
const ids = (chatIds.length > 0 ? chatIds : allIds).sort((a, b) => {
|
|
1952
|
+
const rank = (id) => (/^gpt/i.test(id) ? 0 : /^o\d/i.test(id) ? 1 : 2);
|
|
1953
|
+
return rank(a) - rank(b) || a.localeCompare(b);
|
|
1954
|
+
});
|
|
1955
|
+
models.push(...ids.map((id) => ({ provider, id })));
|
|
1956
|
+
}
|
|
1957
|
+
catch {
|
|
1958
|
+
// No reachable model list for this provider; skip it.
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return models;
|
|
1962
|
+
};
|
|
1963
|
+
const pickModel = async (kind) => {
|
|
1964
|
+
const available = await collectAvailableModels();
|
|
1965
|
+
if (available.length === 0) {
|
|
1966
|
+
writeLine(streams.stdout, 'No models available yet — sign in to a provider first (menu 1).');
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
const shown = available.slice(0, 30);
|
|
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;
|
|
1982
|
+
}
|
|
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
|
+
}
|
|
1990
|
+
writeLine(streams.stdout, 'Use provider:model, e.g. anthropic:claude-opus-5.');
|
|
905
1991
|
return undefined;
|
|
906
1992
|
}
|
|
907
|
-
|
|
908
|
-
|
|
1993
|
+
// A typed id that appears in the collected list belongs to that
|
|
1994
|
+
// provider, wherever the default currently points.
|
|
1995
|
+
const listed = available.find((entry) => entry.id === typed);
|
|
1996
|
+
const inferred = listed?.provider
|
|
1997
|
+
?? (state.provider !== 'demo' ? state.provider : available[0].provider);
|
|
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];
|
|
2010
|
+
}
|
|
2011
|
+
if (!choice) {
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
if (kind === 'default') {
|
|
2015
|
+
await switchDefaultProvider(choice.provider);
|
|
2016
|
+
state.model = choice.id;
|
|
2017
|
+
writeLine(streams.stdout, `Default model set to ${choice.id} (${choice.provider}).`);
|
|
2018
|
+
// A soul's model pin outranks the config at run time, so a silent
|
|
2019
|
+
// mismatch here would make this choice a no-op.
|
|
2020
|
+
if (state.soulPath) {
|
|
2021
|
+
try {
|
|
2022
|
+
const soul = parseSoul(await readFile(state.soulPath, 'utf8'), { seed: state.soulPath });
|
|
2023
|
+
if (soul.model && soul.model !== choice.id
|
|
2024
|
+
&& (soul.provider === undefined || soul.provider === choice.provider)) {
|
|
2025
|
+
writeLine(streams.stdout, `Heads up: your default agent (${soul.agent.name}) pins model ${soul.model} in their soul, which outranks this choice at run time. Edit ${state.soulPath} or clear the agent (menu 3).`);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
catch {
|
|
2029
|
+
// A broken soul file surfaces when it is actually used.
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
else {
|
|
2034
|
+
state.fallbackProvider = choice.provider;
|
|
2035
|
+
state.fallbackModel = choice.id;
|
|
2036
|
+
const openaiEndpoint = (state.provider === 'openai' ? state.baseUrl : undefined)
|
|
2037
|
+
?? state.credentials.openai?.baseUrl;
|
|
2038
|
+
if (choice.provider === 'openai' && openaiEndpoint && openaiEndpoint !== DEFAULT_OPENAI_BASE_URL) {
|
|
2039
|
+
state.fallbackBaseUrl = openaiEndpoint;
|
|
2040
|
+
}
|
|
2041
|
+
else {
|
|
2042
|
+
delete state.fallbackBaseUrl;
|
|
2043
|
+
}
|
|
2044
|
+
if (choice.id === (state.model ?? defaultModelFor(state.provider)) && choice.provider === state.provider) {
|
|
2045
|
+
writeLine(streams.stdout, 'Note: the fallback matches the default model, so it will not add resilience.');
|
|
2046
|
+
}
|
|
2047
|
+
writeLine(streams.stdout, `Fallback model set to ${choice.id} (${choice.provider}) — used when the default model errors mid-run.`);
|
|
2048
|
+
}
|
|
2049
|
+
};
|
|
2050
|
+
const chooseModels = async () => {
|
|
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) {
|
|
2061
|
+
await pickModel('fallback');
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
if (answer.index === 2) {
|
|
2065
|
+
delete state.fallbackModel;
|
|
2066
|
+
delete state.fallbackProvider;
|
|
2067
|
+
writeLine(streams.stdout, 'Fallback cleared.');
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
await pickModel('default');
|
|
2071
|
+
};
|
|
2072
|
+
const chooseAgent = async () => {
|
|
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) {
|
|
2082
|
+
delete state.soulPath;
|
|
2083
|
+
writeLine(streams.stdout, 'Cleared — runs use the built-in default agent.');
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
if (answer.index === 1) {
|
|
2087
|
+
const soulAnswer = await prompter.ask('Path to the soul file: ');
|
|
2088
|
+
if (!soulAnswer) {
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
const resolved = path.resolve(cwd, soulAnswer);
|
|
2092
|
+
try {
|
|
2093
|
+
const soul = parseSoul(await readFile(resolved, 'utf8'), { seed: resolved });
|
|
2094
|
+
state.soulPath = resolved;
|
|
2095
|
+
writeLine(streams.stdout, `Loaded ${soul.agent.name} from ${resolved}.`);
|
|
2096
|
+
}
|
|
2097
|
+
catch (error) {
|
|
2098
|
+
writeLine(streams.stdout, `Could not load that soul file: ${error instanceof Error ? error.message : String(error)}`);
|
|
2099
|
+
}
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
const name = await prompter.ask('Name your agent (Enter to have one generated): ');
|
|
2103
|
+
const instructions = await prompter.ask('Describe their personality in a sentence or two (Enter for a starter you can edit later): ');
|
|
2104
|
+
const agent = defineAgent({
|
|
2105
|
+
...(name ? { name } : {}),
|
|
2106
|
+
instructions: instructions || DEFAULT_SOUL_STARTER,
|
|
2107
|
+
});
|
|
2108
|
+
const soul = formatSoul({
|
|
2109
|
+
agent,
|
|
2110
|
+
...(state.provider !== 'demo' ? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) } : {}),
|
|
2111
|
+
});
|
|
2112
|
+
const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
|
|
2113
|
+
await mkdir(path.dirname(soulPath), { recursive: true });
|
|
2114
|
+
await writeFile(soulPath, soul);
|
|
2115
|
+
state.soulPath = soulPath;
|
|
2116
|
+
writeLine(streams.stdout, `Say hello to ${agent.name}.`);
|
|
2117
|
+
writeLine(streams.stdout, `Their soul lives at ${soulPath} — edit it any time to change how they talk.`);
|
|
2118
|
+
};
|
|
2119
|
+
// Mirror the saved config's fallback so option 4 exercises the same
|
|
2120
|
+
// failover a real run would perform.
|
|
2121
|
+
const buildTestFallback = () => {
|
|
2122
|
+
if (!state.fallbackModel || state.provider === 'demo') {
|
|
2123
|
+
return undefined;
|
|
2124
|
+
}
|
|
2125
|
+
const fallbackProvider = (state.fallbackProvider ?? state.provider);
|
|
2126
|
+
const envKey = (fallbackProvider === state.provider
|
|
2127
|
+
? readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
2128
|
+
: undefined)
|
|
2129
|
+
?? readNonEmptyString(processEnv[defaultKeyEnvFor(fallbackProvider)]);
|
|
2130
|
+
const credential = envKey ? undefined : state.credentials[fallbackProvider];
|
|
2131
|
+
const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
|
|
2132
|
+
const authToken = credential?.type === 'oauth_token' ? credential.value : undefined;
|
|
2133
|
+
if (!apiKey && !authToken) {
|
|
2134
|
+
return undefined;
|
|
2135
|
+
}
|
|
2136
|
+
return {
|
|
2137
|
+
provider: fallbackProvider,
|
|
2138
|
+
model: state.fallbackModel,
|
|
2139
|
+
...(fallbackProvider === 'openai'
|
|
2140
|
+
? {
|
|
2141
|
+
baseUrl: (credential?.type === 'api_key' ? credential.baseUrl : undefined)
|
|
2142
|
+
?? state.fallbackBaseUrl
|
|
2143
|
+
?? (state.provider === 'openai' ? state.baseUrl : undefined)
|
|
2144
|
+
?? DEFAULT_OPENAI_BASE_URL,
|
|
2145
|
+
}
|
|
2146
|
+
: (() => {
|
|
2147
|
+
const url = (fallbackProvider === state.provider ? state.baseUrl : undefined)
|
|
2148
|
+
?? (credential?.type === 'api_key' ? credential.baseUrl : undefined);
|
|
2149
|
+
return url ? { baseUrl: url } : {};
|
|
2150
|
+
})()),
|
|
2151
|
+
...(apiKey ? { apiKey } : {}),
|
|
2152
|
+
...(authToken ? { authToken } : {}),
|
|
2153
|
+
};
|
|
2154
|
+
};
|
|
2155
|
+
const buildTestRuntime = async () => {
|
|
2156
|
+
let soul;
|
|
2157
|
+
if (state.soulPath) {
|
|
2158
|
+
try {
|
|
2159
|
+
soul = parseSoul(await readFile(state.soulPath, 'utf8'), { seed: state.soulPath });
|
|
2160
|
+
}
|
|
2161
|
+
catch (error) {
|
|
2162
|
+
writeLine(streams.stdout, `Warning: could not load the soul file (${error instanceof Error ? error.message : String(error)}); testing without it.`);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
if (state.provider === 'demo') {
|
|
2166
|
+
return { provider: 'demo', ...(soul ? { soul } : {}) };
|
|
2167
|
+
}
|
|
2168
|
+
// Mirror resolveRuntimeConfig exactly: environment keys (including the
|
|
2169
|
+
// STRATUS_API_KEY_ENV selector) outrank the stored sign-in, and a
|
|
2170
|
+
// stored key's bound endpoint is authoritative — so the inline test
|
|
2171
|
+
// exercises precisely what a real run will use.
|
|
2172
|
+
const keyEnv = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
|
|
2173
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
|
|
2174
|
+
?? state.apiKeyEnv
|
|
2175
|
+
?? defaultKeyEnvFor(state.provider);
|
|
2176
|
+
const envKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
2177
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
|
|
2178
|
+
?? readNonEmptyString(processEnv[String(keyEnv)]);
|
|
2179
|
+
const credential = envKey ? undefined : state.credentials[state.provider];
|
|
2180
|
+
const boundUrl = credential?.type === 'api_key' ? credential.baseUrl : undefined;
|
|
2181
|
+
const model = state.model ?? defaultModelFor(state.provider);
|
|
2182
|
+
if (state.provider === 'anthropic') {
|
|
2183
|
+
const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
|
|
2184
|
+
const authToken = credential?.type === 'oauth_token' ? credential.value : undefined;
|
|
2185
|
+
if (!apiKey && !authToken) {
|
|
2186
|
+
writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export ANTHROPIC_API_KEY).');
|
|
909
2187
|
return undefined;
|
|
910
2188
|
}
|
|
2189
|
+
const fallback = buildTestFallback();
|
|
2190
|
+
const anthropicUrl = boundUrl ?? state.baseUrl;
|
|
911
2191
|
return {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
...(
|
|
2192
|
+
provider: 'anthropic',
|
|
2193
|
+
model,
|
|
2194
|
+
...(anthropicUrl ? { baseUrl: anthropicUrl } : {}),
|
|
2195
|
+
...(apiKey ? { apiKey } : {}),
|
|
2196
|
+
...(authToken ? { authToken } : {}),
|
|
2197
|
+
...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
|
|
2198
|
+
...(env.fetch ? { fetch: env.fetch } : {}),
|
|
2199
|
+
...(soul ? { soul } : {}),
|
|
2200
|
+
...(fallback ? { fallback } : {}),
|
|
915
2201
|
};
|
|
2202
|
+
}
|
|
2203
|
+
const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
|
|
2204
|
+
if (!apiKey) {
|
|
2205
|
+
writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export OPENAI_API_KEY).');
|
|
2206
|
+
return undefined;
|
|
2207
|
+
}
|
|
2208
|
+
const fallback = buildTestFallback();
|
|
2209
|
+
return {
|
|
2210
|
+
provider: 'openai',
|
|
2211
|
+
model,
|
|
2212
|
+
baseUrl: boundUrl ?? state.baseUrl ?? DEFAULT_OPENAI_BASE_URL,
|
|
2213
|
+
apiKey,
|
|
2214
|
+
...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
|
|
2215
|
+
...(env.fetch ? { fetch: env.fetch } : {}),
|
|
2216
|
+
...(soul ? { soul } : {}),
|
|
2217
|
+
...(fallback ? { fallback } : {}),
|
|
916
2218
|
};
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
2219
|
+
};
|
|
2220
|
+
const testRun = async () => {
|
|
2221
|
+
const runtime = await buildTestRuntime();
|
|
2222
|
+
if (!runtime) {
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
writeLine(streams.stdout, `Running a quick hello (${formatRuntimeBanner(runtime).replace('Starting Stratus Agent local loop with ', '')})…`);
|
|
2226
|
+
try {
|
|
2227
|
+
const session = await runSingleLoop('Say hello and introduce yourself in one short sentence.', streams, { events: false, runtime, env });
|
|
2228
|
+
printSessionSummary(session, streams);
|
|
2229
|
+
writeLine(streams.stdout);
|
|
2230
|
+
}
|
|
2231
|
+
catch (error) {
|
|
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).');
|
|
920
2236
|
}
|
|
921
|
-
if (
|
|
922
|
-
writeLine(streams.stdout, '
|
|
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.');
|
|
923
2239
|
}
|
|
924
|
-
if (
|
|
925
|
-
writeLine(streams.stdout);
|
|
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.');
|
|
926
2242
|
}
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
const detectEnvOverride = (primary, legacy, chosen, flagName) => {
|
|
2246
|
+
const envVar = readNonEmptyString(processEnv[primary])
|
|
2247
|
+
? primary
|
|
2248
|
+
: readNonEmptyString(processEnv[legacy])
|
|
2249
|
+
? legacy
|
|
2250
|
+
: undefined;
|
|
2251
|
+
if (!envVar) {
|
|
2252
|
+
return undefined;
|
|
2253
|
+
}
|
|
2254
|
+
const envValue = String(processEnv[envVar]).trim();
|
|
2255
|
+
if (envValue === chosen) {
|
|
2256
|
+
return undefined;
|
|
2257
|
+
}
|
|
2258
|
+
return {
|
|
2259
|
+
envVar,
|
|
2260
|
+
envValue,
|
|
2261
|
+
...(flagName ? { flag: `${flagName} ${quoteShellArg(chosen)}` } : {}),
|
|
927
2262
|
};
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
2263
|
+
};
|
|
2264
|
+
const save = async () => {
|
|
2265
|
+
let shadowConfigFlag = '';
|
|
2266
|
+
const config = { provider: state.provider };
|
|
2267
|
+
if (state.provider !== 'demo') {
|
|
2268
|
+
config.model = state.model ?? defaultModelFor(state.provider);
|
|
2269
|
+
}
|
|
2270
|
+
if (state.provider === 'openai') {
|
|
2271
|
+
config.baseUrl = state.baseUrl ?? state.credentials.openai?.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
|
|
2272
|
+
}
|
|
2273
|
+
else if (state.provider === 'anthropic' && state.baseUrl) {
|
|
2274
|
+
// A configured anthropic endpoint (a proxy) must survive re-running
|
|
2275
|
+
// setup, or runs silently revert to the official endpoint.
|
|
2276
|
+
config.baseUrl = state.baseUrl;
|
|
2277
|
+
}
|
|
2278
|
+
if (state.apiKeyEnv) {
|
|
2279
|
+
config.apiKeyEnv = state.apiKeyEnv;
|
|
2280
|
+
}
|
|
2281
|
+
if (state.systemPrompt) {
|
|
2282
|
+
config.systemPrompt = state.systemPrompt;
|
|
2283
|
+
}
|
|
2284
|
+
if (state.soulPath) {
|
|
2285
|
+
config.soul = state.soulPath;
|
|
942
2286
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
config.systemPrompt = systemPrompt;
|
|
2287
|
+
if (state.provider !== 'demo' && state.fallbackModel) {
|
|
2288
|
+
config.fallbackModel = state.fallbackModel;
|
|
2289
|
+
config.fallbackProvider = state.fallbackProvider ?? state.provider;
|
|
2290
|
+
if (config.fallbackProvider === 'openai' && state.fallbackBaseUrl) {
|
|
2291
|
+
config.fallbackBaseUrl = state.fallbackBaseUrl;
|
|
2292
|
+
}
|
|
950
2293
|
}
|
|
2294
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
951
2295
|
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
952
|
-
const conflicts = [
|
|
953
|
-
detectEnvOverride('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER', provider, '--provider'),
|
|
954
|
-
detectEnvOverride('STRATUS_MODEL', 'STRATUSCLAW_MODEL', model, '--model'),
|
|
955
|
-
detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', baseUrl, '--base-url'),
|
|
956
|
-
// No run flag exists for the system prompt, so this one is warn-only.
|
|
957
|
-
detectEnvOverride('STRATUS_SYSTEM_PROMPT', 'STRATUSCLAW_SYSTEM_PROMPT', systemPrompt),
|
|
958
|
-
].filter((conflict) => conflict !== undefined);
|
|
959
|
-
const extraFlags = conflicts.flatMap((conflict) => (conflict.flag ? [` ${conflict.flag}`] : [])).join('');
|
|
960
2296
|
writeLine(streams.stdout);
|
|
961
2297
|
writeLine(streams.stdout, `Wrote ${configPath}`);
|
|
2298
|
+
// A project-local config in this directory outranks the global file for
|
|
2299
|
+
// bare runs started here — say so, and make the suggested command pick
|
|
2300
|
+
// the file that was just written.
|
|
2301
|
+
if (configPath === globalConfigPath(env)) {
|
|
2302
|
+
for (const shadow of [DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME]) {
|
|
2303
|
+
const shadowPath = path.join(cwd, shadow);
|
|
2304
|
+
try {
|
|
2305
|
+
await readFile(shadowPath, 'utf8');
|
|
2306
|
+
writeLine(streams.stdout, `Note: ${shadowPath} exists and takes precedence over the global config for runs started in this directory.`);
|
|
2307
|
+
writeLine(streams.stdout, 'The suggested commands below include --config so they use what you just saved.');
|
|
2308
|
+
shadowConfigFlag = ` --config ${quoteShellArg(configPath)}`;
|
|
2309
|
+
break;
|
|
2310
|
+
}
|
|
2311
|
+
catch (error) {
|
|
2312
|
+
if (error.code !== 'ENOENT') {
|
|
2313
|
+
throw error;
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
if (state.credentialsDirty) {
|
|
2319
|
+
await saveCredentials(env, state.credentials);
|
|
2320
|
+
writeLine(streams.stdout, `Saved your sign-in to ${credentialsPath(env)} (readable only by you).`);
|
|
2321
|
+
}
|
|
962
2322
|
writeLine(streams.stdout);
|
|
963
|
-
|
|
964
|
-
//
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
if (
|
|
979
|
-
writeLine(streams.stdout,
|
|
980
|
-
|
|
2323
|
+
// Exported STRATUS_* variables outrank the config file, so warn when one
|
|
2324
|
+
// would make `stratus run` behave differently from what was just saved.
|
|
2325
|
+
const conflicts = [
|
|
2326
|
+
detectEnvOverride('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER', state.provider, '--provider'),
|
|
2327
|
+
...(state.provider !== 'demo'
|
|
2328
|
+
? [detectEnvOverride('STRATUS_MODEL', 'STRATUSCLAW_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
|
|
2329
|
+
: []),
|
|
2330
|
+
...(state.provider === 'openai'
|
|
2331
|
+
? [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', state.baseUrl ?? DEFAULT_OPENAI_BASE_URL, '--base-url')]
|
|
2332
|
+
: [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', '')]),
|
|
2333
|
+
detectEnvOverride('STRATUS_SYSTEM_PROMPT', 'STRATUSCLAW_SYSTEM_PROMPT', state.systemPrompt ?? ''),
|
|
2334
|
+
].filter((conflict) => conflict !== undefined);
|
|
2335
|
+
for (const conflict of conflicts) {
|
|
2336
|
+
writeLine(streams.stdout, `Note: ${conflict.envVar}=${conflict.envValue} is exported and takes precedence over the config file (run \`unset ${conflict.envVar}\` to clear it).`);
|
|
2337
|
+
}
|
|
2338
|
+
if (conflicts.some((conflict) => conflict.flag)) {
|
|
2339
|
+
writeLine(streams.stdout, 'The suggested commands below include flags so they use what you just configured.');
|
|
2340
|
+
}
|
|
2341
|
+
if (conflicts.length > 0) {
|
|
981
2342
|
writeLine(streams.stdout);
|
|
982
2343
|
}
|
|
983
|
-
|
|
984
|
-
|
|
2344
|
+
const extraFlags = conflicts.flatMap((conflict) => (conflict.flag ? [` ${conflict.flag}`] : [])).join('');
|
|
2345
|
+
if (state.provider === 'demo') {
|
|
2346
|
+
writeLine(streams.stdout, 'You are ready to go — no account needed. Try:');
|
|
985
2347
|
}
|
|
986
|
-
else if (processEnv
|
|
987
|
-
writeLine(streams.stdout,
|
|
2348
|
+
else if (readNonEmptyString(processEnv.STRATUS_API_KEY)) {
|
|
2349
|
+
writeLine(streams.stdout, 'STRATUS_API_KEY is exported and takes precedence over your saved sign-in. You are ready to go. Try:');
|
|
2350
|
+
}
|
|
2351
|
+
else if (state.credentials[state.provider]) {
|
|
2352
|
+
writeLine(streams.stdout, `You are ${signInSummary()} — ready to go. Try:`);
|
|
988
2353
|
}
|
|
989
2354
|
else {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
2355
|
+
const keyEnv = state.apiKeyEnv ?? defaultKeyEnvFor(state.provider);
|
|
2356
|
+
if (readNonEmptyString(processEnv[keyEnv])) {
|
|
2357
|
+
writeLine(streams.stdout, `${keyEnv} is set in your environment — you are ready to go. Try:`);
|
|
2358
|
+
}
|
|
2359
|
+
else {
|
|
2360
|
+
writeLine(streams.stdout, 'You are NOT signed in yet — re-run `stratus setup` and pick option 1, or:');
|
|
2361
|
+
writeLine(streams.stdout, ` export ${keyEnv}=your-key`);
|
|
2362
|
+
writeLine(streams.stdout);
|
|
2363
|
+
writeLine(streams.stdout, 'Then try:');
|
|
2364
|
+
}
|
|
994
2365
|
}
|
|
995
|
-
writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag} "say hello"`);
|
|
2366
|
+
writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag}${shadowConfigFlag} "say hello"`);
|
|
996
2367
|
writeLine(streams.stdout, ' stratus dashboard');
|
|
2368
|
+
};
|
|
2369
|
+
try {
|
|
2370
|
+
writeLine(streams.stdout, 'Stratus Agent setup');
|
|
2371
|
+
writeLine(streams.stdout, 'Pick a provider, sign in, and create your agent — all from this menu.');
|
|
2372
|
+
if (envConfigVar && !command.configPath) {
|
|
2373
|
+
writeLine(streams.stdout, `${envConfigVar} is set, so the config will be written to ${configPath}.`);
|
|
2374
|
+
}
|
|
2375
|
+
while (true) {
|
|
2376
|
+
writeLine(streams.stdout);
|
|
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) {
|
|
2386
|
+
break;
|
|
2387
|
+
}
|
|
2388
|
+
if (choice.index === 0) {
|
|
2389
|
+
await chooseProviders();
|
|
2390
|
+
}
|
|
2391
|
+
else if (choice.index === 1) {
|
|
2392
|
+
await chooseModels();
|
|
2393
|
+
}
|
|
2394
|
+
else if (choice.index === 2) {
|
|
2395
|
+
await chooseAgent();
|
|
2396
|
+
}
|
|
2397
|
+
else if (choice.index === 3) {
|
|
2398
|
+
await testRun();
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
await save();
|
|
997
2402
|
return 0;
|
|
998
2403
|
}
|
|
999
2404
|
finally {
|
|
@@ -1009,6 +2414,10 @@ export const runAgentNew = (command, streams) => {
|
|
|
1009
2414
|
writeLine(streams.stdout, JSON.stringify(agent, null, 2));
|
|
1010
2415
|
return 0;
|
|
1011
2416
|
}
|
|
2417
|
+
if (command.format === 'soul') {
|
|
2418
|
+
streams.stdout.write(formatSoul({ agent, provider: 'anthropic', model: DEFAULT_ANTHROPIC_MODEL }));
|
|
2419
|
+
return 0;
|
|
2420
|
+
}
|
|
1012
2421
|
writeLine(streams.stdout, `Say hello to ${agent.name}.`);
|
|
1013
2422
|
writeLine(streams.stdout);
|
|
1014
2423
|
writeLine(streams.stdout, ` id ${agent.id}`);
|
|
@@ -1017,7 +2426,11 @@ export const runAgentNew = (command, streams) => {
|
|
|
1017
2426
|
writeLine(streams.stdout, ` soul ${agent.instructions}`);
|
|
1018
2427
|
}
|
|
1019
2428
|
writeLine(streams.stdout);
|
|
1020
|
-
writeLine(streams.stdout, '
|
|
2429
|
+
writeLine(streams.stdout, 'Save this as a soul file and run it:');
|
|
2430
|
+
writeLine(streams.stdout, ` stratus agent new --name ${quoteShellArg(agent.name)}${command.instructions ? ` --instructions ${quoteShellArg(command.instructions)}` : ''} --format soul > my-agent.md`);
|
|
2431
|
+
writeLine(streams.stdout, ' stratus run --soul my-agent.md "hello"');
|
|
2432
|
+
writeLine(streams.stdout);
|
|
2433
|
+
writeLine(streams.stdout, 'Definition (JSON):');
|
|
1021
2434
|
writeLine(streams.stdout, JSON.stringify(agent, null, 2));
|
|
1022
2435
|
return 0;
|
|
1023
2436
|
};
|
|
@@ -1082,7 +2495,9 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
1082
2495
|
if (command.format === 'json') {
|
|
1083
2496
|
writeLine(streams.stdout, JSON.stringify({
|
|
1084
2497
|
provider: runtime.provider,
|
|
1085
|
-
|
|
2498
|
+
// Replay state (Claude's raw thinking turns) stays in the stored
|
|
2499
|
+
// session but never in user-facing output.
|
|
2500
|
+
session: redactAnthropicRawTurns(session),
|
|
1086
2501
|
}, null, 2));
|
|
1087
2502
|
return 0;
|
|
1088
2503
|
}
|