@stratusagent/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, readFile, 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 { defineAgent } from '@stratusagent/agents';
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 Interactive walkthrough that writes stratus.config.json
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 json
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: demo or openai
46
- --model Model name for real providers (default: gpt-4.1-mini)
47
- --base-url Override the OpenAI-compatible API base URL
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 by default, or a path from --config / STRATUS_CONFIG.
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,55 @@ const createDemoProvider = () => defineProvider({
122
141
  return builder.done();
123
142
  },
124
143
  });
144
+ const MEMORY_FILE_RELATIVE_PATH = path.join('.stratus', 'memory.jsonl');
145
+ // Agents keep the same memory across CLI runs: every remembered fact lands
146
+ // in .stratus/memory.jsonl (keyed by agent id), so the Ava you talk to
147
+ // tomorrow is the Ava you talked to today. One JSON entry per line, written
148
+ // with O_APPEND: concurrent runs each add their own line instead of
149
+ // re-writing the file, so no run can clobber another's remembered fact.
150
+ export const createFileMemoryStore = (filePath) => {
151
+ const readEntries = async () => {
152
+ let raw;
153
+ try {
154
+ raw = await readFile(filePath, 'utf8');
155
+ }
156
+ catch (error) {
157
+ if (error.code === 'ENOENT') {
158
+ return [];
159
+ }
160
+ throw error;
161
+ }
162
+ return raw
163
+ .split('\n')
164
+ .filter((line) => line.trim().length > 0)
165
+ .map((line) => {
166
+ try {
167
+ return JSON.parse(line);
168
+ }
169
+ catch {
170
+ throw new Error(`Memory file has an invalid line: ${filePath}`);
171
+ }
172
+ });
173
+ };
174
+ return {
175
+ async append(agentId, content, metadata) {
176
+ const entry = {
177
+ id: `${agentId}:memory:${randomUUID()}`,
178
+ agentId,
179
+ content,
180
+ createdAt: new Date().toISOString(),
181
+ ...(metadata ? { metadata } : {}),
182
+ };
183
+ await mkdir(path.dirname(filePath), { recursive: true });
184
+ await appendFile(filePath, `${JSON.stringify(entry)}\n`);
185
+ return entry;
186
+ },
187
+ async list(agentId) {
188
+ const entries = await readEntries();
189
+ return entries.filter((entry) => entry.agentId === agentId);
190
+ },
191
+ };
192
+ };
125
193
  const readPromptFromEnvironment = (env) => (env.stdin ?? '').trim();
126
194
  const readPromptFromStdin = async (stdin) => {
127
195
  stdin.setEncoding('utf8');
@@ -132,13 +200,53 @@ const readPromptFromStdin = async (stdin) => {
132
200
  return data.trim();
133
201
  };
134
202
  const parseProviderName = (value, label) => {
135
- if (value === 'demo' || value === 'openai') {
203
+ if (value === 'demo' || value === 'openai' || value === 'anthropic') {
136
204
  return value;
137
205
  }
138
206
  throw new Error(`Unsupported provider in ${label}: ${value}`);
139
207
  };
140
208
  const readProcessEnv = (env) => env.processEnv ?? process.env;
141
209
  const readWorkingDirectory = (env) => env.cwd ?? process.cwd();
210
+ const readHomeDirectory = (env) => env.homeDir ?? os.homedir();
211
+ const stratusHomePath = (env) => path.join(readHomeDirectory(env), STRATUS_HOME_DIRNAME);
212
+ const globalConfigPath = (env) => path.join(stratusHomePath(env), GLOBAL_CONFIG_FILENAME);
213
+ const credentialsPath = (env) => path.join(stratusHomePath(env), CREDENTIALS_FILENAME);
214
+ const agentsDirPath = (env) => path.join(stratusHomePath(env), AGENTS_DIRNAME);
215
+ const loadCredentials = async (env) => {
216
+ let raw;
217
+ try {
218
+ raw = await readFile(credentialsPath(env), 'utf8');
219
+ }
220
+ catch (error) {
221
+ if (error.code === 'ENOENT') {
222
+ return {};
223
+ }
224
+ throw error;
225
+ }
226
+ const parsed = JSON.parse(raw);
227
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
228
+ throw new Error(`Credentials file must contain a JSON object: ${credentialsPath(env)}`);
229
+ }
230
+ const credentials = {};
231
+ for (const provider of ['anthropic', 'openai']) {
232
+ const entry = parsed[provider];
233
+ if (typeof entry === 'object' && entry !== null && !Array.isArray(entry) &&
234
+ (entry.type === 'api_key' || entry.type === 'oauth_token') &&
235
+ typeof entry.value === 'string') {
236
+ credentials[provider] = entry;
237
+ }
238
+ }
239
+ return credentials;
240
+ };
241
+ // Credentials never live in a project directory or a shell profile — they
242
+ // are written once by `stratus setup` and read on every run, 0600 so only
243
+ // the owner can read them.
244
+ const saveCredentials = async (env, credentials) => {
245
+ const filePath = credentialsPath(env);
246
+ await mkdir(path.dirname(filePath), { recursive: true });
247
+ await writeFile(filePath, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
248
+ await chmod(filePath, 0o600);
249
+ };
142
250
  const readOptionValue = (tokens, index, flag) => {
143
251
  const value = tokens[index + 1];
144
252
  if (!value) {
@@ -216,7 +324,7 @@ export const parseCommand = (argv, env = {}) => {
216
324
  }
217
325
  if (token === '--format') {
218
326
  const value = readOptionValue(agentRest, index, '--format');
219
- if (value !== 'text' && value !== 'json') {
327
+ if (value !== 'text' && value !== 'json' && value !== 'soul') {
220
328
  throw new Error(`Unsupported format: ${value}`);
221
329
  }
222
330
  format = value;
@@ -259,6 +367,7 @@ export const parseCommand = (argv, env = {}) => {
259
367
  let model;
260
368
  let baseUrl;
261
369
  let configPath;
370
+ let soul;
262
371
  let format = 'text';
263
372
  let events = true;
264
373
  let approvals = 'always';
@@ -302,6 +411,11 @@ export const parseCommand = (argv, env = {}) => {
302
411
  index += 1;
303
412
  continue;
304
413
  }
414
+ if (token === '--soul') {
415
+ soul = readOptionValue(rest, index, '--soul');
416
+ index += 1;
417
+ continue;
418
+ }
305
419
  if (token === '--format') {
306
420
  const value = readOptionValue(rest, index, '--format');
307
421
  if (value !== 'text' && value !== 'json') {
@@ -357,6 +471,7 @@ export const parseCommand = (argv, env = {}) => {
357
471
  ...(model ? { model } : {}),
358
472
  ...(baseUrl ? { baseUrl } : {}),
359
473
  ...(configPath ? { configPath } : {}),
474
+ ...(soul ? { soul } : {}),
360
475
  format,
361
476
  events,
362
477
  approvals,
@@ -408,20 +523,38 @@ const loadConfigFile = async (configPath) => {
408
523
  if (typeof config.systemPrompt === 'string' && config.systemPrompt.length > 0) {
409
524
  resolved.systemPrompt = config.systemPrompt;
410
525
  }
526
+ if (typeof config.soul === 'string' && config.soul.length > 0) {
527
+ resolved.soul = config.soul;
528
+ }
529
+ if (typeof config.fallbackModel === 'string' && config.fallbackModel.length > 0) {
530
+ resolved.fallbackModel = config.fallbackModel;
531
+ }
532
+ if (typeof config.fallbackProvider === 'string') {
533
+ resolved.fallbackProvider = parseProviderName(config.fallbackProvider, `config ${configPath}`);
534
+ }
535
+ if (typeof config.fallbackBaseUrl === 'string' && config.fallbackBaseUrl.length > 0) {
536
+ resolved.fallbackBaseUrl = config.fallbackBaseUrl;
537
+ }
411
538
  return resolved;
412
539
  };
413
- const resolveConfigPath = async (command, env) => {
540
+ const resolveConfigLocation = async (command, env) => {
414
541
  const processEnv = readProcessEnv(env);
415
542
  const cwd = readWorkingDirectory(env);
416
543
  const explicit = command.configPath ?? processEnv.STRATUS_CONFIG ?? processEnv.STRATUSCLAW_CONFIG;
417
544
  if (explicit) {
418
- return path.resolve(cwd, explicit);
545
+ return { path: path.resolve(cwd, explicit), trusted: true };
419
546
  }
420
- for (const candidate of [DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME]) {
421
- const candidatePath = path.join(cwd, candidate);
547
+ // Project-local configs win; the global ~/.stratus/config.json written by
548
+ // `stratus setup` is the fallback that makes the CLI work from anywhere.
549
+ const candidates = [
550
+ { path: path.join(cwd, DEFAULT_CONFIG_FILENAME), trusted: false },
551
+ { path: path.join(cwd, LEGACY_CONFIG_FILENAME), trusted: false },
552
+ { path: globalConfigPath(env), trusted: true },
553
+ ];
554
+ for (const candidate of candidates) {
422
555
  try {
423
- await readFile(candidatePath, 'utf8');
424
- return candidatePath;
556
+ await readFile(candidate.path, 'utf8');
557
+ return candidate;
425
558
  }
426
559
  catch (error) {
427
560
  const code = error.code;
@@ -442,44 +575,145 @@ const readNonEmptyString = (value, map) => {
442
575
  }
443
576
  return map ? map(trimmed) : trimmed;
444
577
  };
578
+ // A soul travels with the run: --soul outranks STRATUS_SOUL, which outranks
579
+ // the config file's "soul" key.
580
+ const resolveSoul = async (command, env, fileConfig) => {
581
+ const processEnv = readProcessEnv(env);
582
+ const soulPath = command.soul
583
+ ?? readNonEmptyString(processEnv.STRATUS_SOUL)
584
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
585
+ ?? fileConfig.soul;
586
+ if (!soulPath) {
587
+ return undefined;
588
+ }
589
+ const resolvedPath = path.resolve(readWorkingDirectory(env), String(soulPath));
590
+ let raw;
591
+ try {
592
+ raw = await readFile(resolvedPath, 'utf8');
593
+ }
594
+ catch (error) {
595
+ if (error.code === 'ENOENT') {
596
+ throw new Error(`Soul file not found: ${resolvedPath}`);
597
+ }
598
+ throw error;
599
+ }
600
+ try {
601
+ // Seeding with the resolved path keeps an unnamed soul's generated
602
+ // identity (name, id, avatar) stable across runs — persisted memory is
603
+ // keyed by that id, so it must not change between invocations.
604
+ return parseSoul(raw, { seed: resolvedPath });
605
+ }
606
+ catch (error) {
607
+ throw new Error(`Could not parse soul file ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`);
608
+ }
609
+ };
445
610
  export const resolveRuntimeConfig = async (command, env = {}) => {
446
611
  const processEnv = readProcessEnv(env);
447
- const configPath = await resolveConfigPath(command, env);
448
- const fileConfig = configPath ? await loadConfigFile(configPath) : {};
612
+ const configLocation = await resolveConfigLocation(command, env);
613
+ const fileConfig = configLocation ? await loadConfigFile(configLocation.path) : {};
614
+ const soul = await resolveSoul(command, env, fileConfig);
615
+ // Explicit flags and env vars outrank the soul's own provider/model hints,
616
+ // which outrank the config file's defaults.
449
617
  const provider = command.provider
450
618
  ?? readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
451
619
  ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
620
+ ?? readNonEmptyString(soul?.provider, (value) => parseProviderName(value, 'soul file'))
452
621
  ?? fileConfig.provider
453
622
  ?? 'demo';
454
623
  if (provider === 'demo') {
455
- return { provider: 'demo' };
624
+ return { provider: 'demo', ...(soul ? { soul } : {}) };
456
625
  }
626
+ // A config file's model/baseUrl/apiKeyEnv were written for the provider
627
+ // named in that file. When a flag, env var, or soul selects a different
628
+ // provider, those values would point at the wrong API (e.g. an OpenAI
629
+ // base URL handed to the Anthropic SDK), so they are ignored. A file with
630
+ // no provider key predates the anthropic option, so its settings are
631
+ // treated as openai-specific.
632
+ const fileConfigApplies = (fileConfig.provider ?? 'openai') === provider;
633
+ // A soul's model was chosen for the soul's own provider. If a flag or env
634
+ // var overrides that provider, the model hint would target the wrong API
635
+ // (e.g. a Claude model sent to OpenAI), so it only applies when the soul
636
+ // names no provider or names the selected one.
637
+ const soulModelApplies = soul?.provider === undefined || soul.provider === provider;
457
638
  const model = command.model
458
639
  ?? readNonEmptyString(processEnv.STRATUS_MODEL)
459
640
  ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
460
- ?? fileConfig.model
461
- ?? DEFAULT_OPENAI_MODEL;
462
- const baseUrl = command.baseUrl
463
- ?? readNonEmptyString(processEnv.STRATUS_BASE_URL)
464
- ?? readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL)
465
- ?? fileConfig.baseUrl
466
- ?? DEFAULT_OPENAI_BASE_URL;
641
+ ?? (soulModelApplies ? readNonEmptyString(soul?.model) : undefined)
642
+ ?? (fileConfigApplies ? fileConfig.model : undefined)
643
+ ?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_MODEL : DEFAULT_OPENAI_MODEL);
467
644
  const apiKeyEnvName = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
468
645
  ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
469
- ?? fileConfig.apiKeyEnv
470
- ?? 'OPENAI_API_KEY';
471
- const apiKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
646
+ ?? (fileConfigApplies ? fileConfig.apiKeyEnv : undefined)
647
+ ?? (provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
648
+ const credentials = await loadCredentials(env);
649
+ // A custom endpoint chosen by an auto-discovered project config is not a
650
+ // place the stored sign-in ever gets sent — a cloned repository could
651
+ // point it anywhere. Flags and env vars are the user's own choice, and
652
+ // the provider's default endpoint is harmless.
653
+ const defaultEndpointFor = (target) => target === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL;
654
+ const untrustedCustomBaseUrl = configLocation?.trusted === false
655
+ && command.baseUrl === undefined
656
+ && readNonEmptyString(processEnv.STRATUS_BASE_URL) === undefined
657
+ && readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL) === undefined
658
+ && fileConfigApplies
659
+ && fileConfig.baseUrl !== undefined
660
+ && fileConfig.baseUrl.replace(/\/+$/, '') !== defaultEndpointFor(String(provider));
661
+ // Env vars outrank the stored sign-in from `stratus setup`.
662
+ const envApiKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
472
663
  ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
473
664
  ?? readNonEmptyString(processEnv[String(apiKeyEnvName)]);
474
- if (!apiKey) {
475
- throw new Error(`Missing API key for provider=openai. Set STRATUS_API_KEY or ${apiKeyEnvName}.`);
476
- }
477
- const resolved = {
478
- provider: 'openai',
479
- model: String(model),
480
- baseUrl: String(baseUrl),
481
- apiKey: String(apiKey),
482
- };
665
+ const candidateCredential = credentials[provider];
666
+ // A bound credential ignores config URLs entirely, so an untrusted
667
+ // project URL cannot redirect it — only unbound stored keys are blocked.
668
+ const credentialIsBound = candidateCredential?.type === 'api_key' && candidateCredential.baseUrl !== undefined;
669
+ const storedCredential = envApiKey || (untrustedCustomBaseUrl && !credentialIsBound)
670
+ ? undefined
671
+ : candidateCredential;
672
+ const apiKey = envApiKey
673
+ ?? (storedCredential?.type === 'api_key' ? storedCredential.value : undefined);
674
+ const authToken = provider === 'anthropic' && storedCredential?.type === 'oauth_token'
675
+ ? storedCredential.value
676
+ : undefined;
677
+ // A stored key bound to an endpoint is used ONLY with that endpoint — a
678
+ // config file can never redirect it, not even to the official default
679
+ // URL (a project config could otherwise reroute a local-service key to
680
+ // the official API). An explicit flag or env URL that disagrees refuses
681
+ // the stored key instead of leaking it. This applies to both providers.
682
+ const boundBaseUrl = !envApiKey && storedCredential?.type === 'api_key'
683
+ ? storedCredential.baseUrl
684
+ : undefined;
685
+ const explicitBaseUrl = command.baseUrl
686
+ ?? readNonEmptyString(processEnv.STRATUS_BASE_URL)
687
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL);
688
+ if (boundBaseUrl && explicitBaseUrl
689
+ && String(explicitBaseUrl).replace(/\/+$/, '') !== boundBaseUrl.replace(/\/+$/, '')) {
690
+ 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.`);
691
+ }
692
+ const baseUrl = boundBaseUrl
693
+ ?? explicitBaseUrl
694
+ ?? (fileConfigApplies ? fileConfig.baseUrl : undefined)
695
+ // The Anthropic SDK knows its own endpoint; only openai needs a default.
696
+ ?? (provider === 'anthropic' ? undefined : DEFAULT_OPENAI_BASE_URL);
697
+ if (!apiKey && !authToken) {
698
+ if (untrustedCustomBaseUrl && credentials[provider]) {
699
+ 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.`);
700
+ }
701
+ throw new Error(`Missing API key for provider=${provider}. Run \`stratus setup\` to sign in, or set STRATUS_API_KEY or ${apiKeyEnvName}.`);
702
+ }
703
+ const resolved = provider === 'anthropic'
704
+ ? {
705
+ provider: 'anthropic',
706
+ model: String(model),
707
+ ...(baseUrl ? { baseUrl: String(baseUrl) } : {}),
708
+ ...(apiKey ? { apiKey: String(apiKey) } : {}),
709
+ ...(authToken ? { authToken } : {}),
710
+ }
711
+ : {
712
+ provider: 'openai',
713
+ model: String(model),
714
+ baseUrl: String(baseUrl),
715
+ apiKey: String(apiKey),
716
+ };
483
717
  const systemPrompt = readNonEmptyString(processEnv.STRATUS_SYSTEM_PROMPT)
484
718
  ?? readNonEmptyString(processEnv.STRATUSCLAW_SYSTEM_PROMPT)
485
719
  ?? fileConfig.systemPrompt;
@@ -489,12 +723,116 @@ export const resolveRuntimeConfig = async (command, env = {}) => {
489
723
  if (env.fetch) {
490
724
  resolved.fetch = env.fetch;
491
725
  }
726
+ if (soul) {
727
+ resolved.soul = soul;
728
+ }
729
+ // A configured fallback model kicks in when the default model errors
730
+ // mid-run. It needs its own working sign-in; without one the fallback is
731
+ // quietly skipped rather than failing the run it exists to rescue.
732
+ // An implicit fallback (no fallbackProvider key) was written for the
733
+ // config's own provider — when a flag, env var, or soul overrides that
734
+ // provider, the fallback model would target the wrong API, so it is
735
+ // ignored. An explicit fallbackProvider stays valid regardless.
736
+ if (fileConfig.fallbackModel && (fileConfig.fallbackProvider !== undefined || fileConfigApplies)) {
737
+ const fallbackProvider = fileConfig.fallbackProvider ?? provider;
738
+ if (fallbackProvider !== 'demo') {
739
+ // Same precedence as the primary sign-in: environment keys outrank
740
+ // the stored credential. And the same endpoint rule: an untrusted
741
+ // project config's custom fallback URL never receives a stored key —
742
+ // including the primary's stored key when both share a provider.
743
+ // Only env-supplied keys follow such a URL.
744
+ const fallbackUntrustedUrl = fallbackProvider === 'openai'
745
+ && configLocation?.trusted === false
746
+ && fileConfig.fallbackBaseUrl !== undefined
747
+ && fileConfig.fallbackBaseUrl.replace(/\/+$/, '') !== DEFAULT_OPENAI_BASE_URL;
748
+ const fallbackEnvKey = readNonEmptyString(processEnv[fallbackProvider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY']);
749
+ const fallbackCredential = fallbackEnvKey || fallbackUntrustedUrl ? undefined : credentials[fallbackProvider];
750
+ const primaryReusable = fallbackProvider === provider
751
+ && (envApiKey !== undefined || !fallbackUntrustedUrl);
752
+ const fallbackApiKey = (primaryReusable ? apiKey : undefined)
753
+ ?? fallbackEnvKey
754
+ ?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.value : undefined);
755
+ const fallbackAuthToken = fallbackProvider !== provider && fallbackProvider === 'anthropic' && fallbackCredential?.type === 'oauth_token'
756
+ ? fallbackCredential.value
757
+ : (primaryReusable ? authToken : undefined);
758
+ if (fallbackApiKey || fallbackAuthToken) {
759
+ // When the fallback key comes out of the credential store (its own
760
+ // entry, or the primary's reused stored key), its bound endpoint is
761
+ // authoritative — config URLs cannot redirect it.
762
+ const fallbackBoundUrl = fallbackCredential?.type === 'api_key'
763
+ ? fallbackCredential.baseUrl
764
+ : (primaryReusable && !envApiKey ? boundBaseUrl : undefined);
765
+ // An anthropic fallback on the same provider keeps the primary's
766
+ // configured endpoint — retrying the same credential against the
767
+ // official endpoint instead of the configured service would leak
768
+ // it and likely fail.
769
+ const fallbackAnthropicBaseUrl = fallbackProvider === 'anthropic'
770
+ ? (fallbackProvider === provider && baseUrl ? String(baseUrl) : undefined)
771
+ ?? (fallbackCredential?.type === 'api_key' ? fallbackCredential.baseUrl : undefined)
772
+ : undefined;
773
+ resolved.fallback = {
774
+ provider: fallbackProvider,
775
+ model: fileConfig.fallbackModel,
776
+ ...(fallbackProvider === 'openai'
777
+ ? {
778
+ baseUrl: fallbackBoundUrl
779
+ ?? fileConfig.fallbackBaseUrl
780
+ ?? (fallbackProvider === provider ? String(baseUrl) : undefined)
781
+ ?? DEFAULT_OPENAI_BASE_URL,
782
+ }
783
+ : (fallbackAnthropicBaseUrl ? { baseUrl: fallbackAnthropicBaseUrl } : {})),
784
+ ...(fallbackApiKey ? { apiKey: String(fallbackApiKey) } : {}),
785
+ ...(fallbackAuthToken ? { authToken: fallbackAuthToken } : {}),
786
+ };
787
+ }
788
+ }
789
+ }
492
790
  return resolved;
493
791
  };
494
- const createRuntimeProvider = (config) => {
792
+ // Wraps the fallback runtime as a provider: the primary model serves every
793
+ // turn until it throws, then the run switches to the fallback for good.
794
+ const createFallbackWrappedProvider = (primary, fallback, onFallback) => {
795
+ let usingFallback = false;
796
+ return {
797
+ name: primary.name,
798
+ async generate(request) {
799
+ if (!usingFallback) {
800
+ try {
801
+ return await primary.generate(request);
802
+ }
803
+ catch (error) {
804
+ usingFallback = true;
805
+ onFallback(error);
806
+ }
807
+ }
808
+ return fallback.generate(request);
809
+ },
810
+ };
811
+ };
812
+ const createRuntimeProvider = (config, onFallback) => {
495
813
  if (config.provider === 'demo') {
496
814
  return createDemoProvider();
497
815
  }
816
+ if (config.fallback) {
817
+ const { fallback, ...primaryConfig } = config;
818
+ const primary = createRuntimeProvider(primaryConfig);
819
+ const fallbackProvider = createRuntimeProvider({
820
+ ...fallback,
821
+ ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
822
+ ...(config.fetch ? { fetch: config.fetch } : {}),
823
+ });
824
+ return createFallbackWrappedProvider(primary, fallbackProvider, onFallback ?? (() => { }));
825
+ }
826
+ if (config.provider === 'anthropic') {
827
+ return createAnthropicProvider({
828
+ model: config.model,
829
+ ...(config.apiKey ? { apiKey: config.apiKey } : {}),
830
+ ...(config.authToken ? { authToken: config.authToken } : {}),
831
+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
832
+ ...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
833
+ ...(config.fetch ? { fetch: config.fetch } : {}),
834
+ });
835
+ }
498
836
  return createOpenAICompatibleProvider({
499
837
  name: 'openai',
500
838
  model: config.model,
@@ -536,8 +874,10 @@ const createApprovalPolicy = (mode, streams, env) => {
536
874
  };
537
875
  };
538
876
  export const runSingleLoop = async (prompt, streams, options) => {
877
+ const memory = createFileMemoryStore(path.join(readWorkingDirectory(options.env ?? {}), MEMORY_FILE_RELATIVE_PATH));
539
878
  const tools = new ToolRegistry();
540
879
  tools.register(createDemoTool());
880
+ tools.register(createRememberTool(memory));
541
881
  const bus = new EventBus({
542
882
  onError: (error) => {
543
883
  writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -551,33 +891,45 @@ export const runSingleLoop = async (prompt, streams, options) => {
551
891
  }
552
892
  });
553
893
  }
554
- const runtimeProvider = createRuntimeProvider(options.runtime);
894
+ const runtimeProvider = createRuntimeProvider(options.runtime, (error) => {
895
+ const fallback = options.runtime.provider === 'demo' ? undefined : options.runtime.fallback;
896
+ writeLine(streams.stderr, `Warning: the default model failed (${error instanceof Error ? error.message : String(error)}); falling back to ${fallback?.model ?? 'the fallback model'}.`);
897
+ });
555
898
  const runner = new AgentRunner({
556
899
  provider: runtimeProvider,
557
900
  tools,
558
901
  executor: createLocalCommandExecutor(),
559
902
  approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}),
560
903
  bus,
904
+ memory,
561
905
  ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
562
906
  });
563
907
  await runner.initialize();
564
- const agent = options.runtime.provider === 'demo'
565
- ? {
566
- id: 'demo-agent',
567
- name: 'Demo Agent',
568
- instructions: 'Keep the loop tiny and readable.',
569
- }
570
- : {
571
- id: 'openai-agent',
572
- name: 'OpenAI Agent',
573
- instructions: 'Respond clearly and directly to the user request.',
574
- };
908
+ // A soul is a full identity — it replaces the built-in per-provider agent.
909
+ const agent = options.runtime.soul?.agent
910
+ ?? (options.runtime.provider === 'demo'
911
+ ? {
912
+ id: 'demo-agent',
913
+ name: 'Demo Agent',
914
+ instructions: 'Keep the loop tiny and readable.',
915
+ }
916
+ : options.runtime.provider === 'anthropic'
917
+ ? {
918
+ id: 'anthropic-agent',
919
+ name: 'Claude Agent',
920
+ instructions: 'Respond clearly and directly to the user request.',
921
+ }
922
+ : {
923
+ id: 'openai-agent',
924
+ name: 'OpenAI Agent',
925
+ instructions: 'Respond clearly and directly to the user request.',
926
+ });
575
927
  const metadata = options.runtime.provider === 'demo'
576
928
  ? { provider: 'demo' }
577
929
  : {
578
- provider: 'openai',
930
+ provider: options.runtime.provider,
579
931
  model: options.runtime.model,
580
- baseUrl: options.runtime.baseUrl,
932
+ ...(options.runtime.provider === 'openai' ? { baseUrl: options.runtime.baseUrl } : {}),
581
933
  };
582
934
  return runner.run({
583
935
  sessionId: randomUUID(),
@@ -609,10 +961,12 @@ export const printSessionSummary = (session, streams) => {
609
961
  }
610
962
  };
611
963
  const formatRuntimeBanner = (runtime) => {
964
+ const soulSuffix = runtime.soul ? ` as ${runtime.soul.agent.name}` : '';
612
965
  if (runtime.provider === 'demo') {
613
- return 'Starting Stratus Agent local loop with provider=demo';
966
+ return `Starting Stratus Agent local loop with provider=demo${soulSuffix}`;
614
967
  }
615
- return `Starting Stratus Agent local loop with provider=openai model=${runtime.model}`;
968
+ const fallbackSuffix = runtime.fallback ? ` fallback=${runtime.fallback.model}` : '';
969
+ return `Starting Stratus Agent local loop with provider=${runtime.provider} model=${runtime.model}${fallbackSuffix}${soulSuffix}`;
616
970
  };
617
971
  const escapeHtml = (value) => value
618
972
  .replaceAll('&', '&')
@@ -754,7 +1108,7 @@ export const startDashboardServer = async (options) => {
754
1108
  sendJson(response, 200, {
755
1109
  ok: true,
756
1110
  service: 'stratus-dashboard',
757
- version: '0.1.0',
1111
+ version: '0.2.0',
758
1112
  now: new Date().toISOString(),
759
1113
  });
760
1114
  return;
@@ -812,7 +1166,24 @@ export const startDashboardServer = async (options) => {
812
1166
  const quoteShellArg = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
813
1167
  const createSetupPrompter = (streams, env) => {
814
1168
  const input = env.setupInput ?? process.stdin;
815
- const readline = createInterface({ input, terminal: false });
1169
+ // On a real TTY the readline must own the terminal so we can suppress the
1170
+ // echo of pasted secrets; piped input (tests, scripts) never echoes.
1171
+ const interactive = env.setupInput === undefined && process.stdin.isTTY === true;
1172
+ const readline = createInterface(interactive
1173
+ ? { input, output: process.stdout, terminal: true }
1174
+ : { input, terminal: false });
1175
+ let secretMode = false;
1176
+ if (interactive) {
1177
+ const internal = readline;
1178
+ const original = internal._writeToOutput?.bind(readline);
1179
+ internal._writeToOutput = (chunk) => {
1180
+ if (!secretMode) {
1181
+ original?.(chunk);
1182
+ }
1183
+ // Secrets: swallow the echo entirely — the prompt itself is written
1184
+ // through streams.stdout before secretMode turns on.
1185
+ };
1186
+ }
816
1187
  const pendingLines = [];
817
1188
  let closed = false;
818
1189
  readline.on('line', (line) => {
@@ -821,179 +1192,897 @@ const createSetupPrompter = (streams, env) => {
821
1192
  readline.once('close', () => {
822
1193
  closed = true;
823
1194
  });
1195
+ const nextLine = async () => {
1196
+ while (pendingLines.length === 0) {
1197
+ if (closed) {
1198
+ return '';
1199
+ }
1200
+ await new Promise((resolve) => {
1201
+ readline.once('line', () => resolve());
1202
+ readline.once('close', () => resolve());
1203
+ });
1204
+ }
1205
+ return (pendingLines.shift() ?? '').trim();
1206
+ };
824
1207
  return {
825
1208
  async ask(question) {
826
1209
  streams.stdout.write(question);
827
- while (pendingLines.length === 0) {
828
- if (closed) {
829
- return '';
1210
+ return nextLine();
1211
+ },
1212
+ async askSecret(question) {
1213
+ streams.stdout.write(question);
1214
+ secretMode = true;
1215
+ try {
1216
+ return await nextLine();
1217
+ }
1218
+ finally {
1219
+ secretMode = false;
1220
+ if (interactive) {
1221
+ streams.stdout.write('\n');
830
1222
  }
831
- await new Promise((resolve) => {
832
- readline.once('line', () => resolve());
833
- readline.once('close', () => resolve());
834
- });
835
1223
  }
836
- const answer = pendingLines.shift() ?? '';
837
- return answer.trim();
1224
+ },
1225
+ isClosed() {
1226
+ return closed && pendingLines.length === 0;
838
1227
  },
839
1228
  close() {
840
1229
  readline.close();
841
1230
  },
842
1231
  };
843
1232
  };
844
- const fileExists = async (filePath) => {
1233
+ // Live check that a pasted key actually works, so the user finds out inside
1234
+ // setup instead of on their first run.
1235
+ const verifyProviderKey = async (provider, key, baseUrl, fetchImpl) => {
1236
+ if (typeof fetchImpl !== 'function') {
1237
+ return { status: 'unreachable', detail: 'fetch is unavailable' };
1238
+ }
1239
+ const root = (baseUrl ?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL)).replace(/\/+$/, '');
1240
+ const url = provider === 'anthropic' ? `${root}/v1/models` : `${root}/models`;
1241
+ const headers = provider === 'anthropic'
1242
+ ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' }
1243
+ : { authorization: `Bearer ${key}` };
845
1244
  try {
846
- await readFile(filePath, 'utf8');
847
- return true;
1245
+ const response = await fetchImpl(url, { headers });
1246
+ if (response.ok) {
1247
+ return { status: 'ok' };
1248
+ }
1249
+ // Only an explicit auth failure condemns the key. Compatible endpoints
1250
+ // (local models, proxies) often lack GET /models entirely — a 404/405
1251
+ // there says nothing about the key, so it stays saveable.
1252
+ if (response.status === 401 || response.status === 403) {
1253
+ return { status: 'rejected', detail: `HTTP ${response.status}` };
1254
+ }
1255
+ return { status: 'unreachable', detail: `the endpoint did not support a key check (HTTP ${response.status})` };
848
1256
  }
849
1257
  catch (error) {
850
- if (error.code === 'ENOENT') {
851
- return false;
852
- }
853
- throw error;
1258
+ return { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) };
854
1259
  }
855
1260
  };
1261
+ const DEFAULT_SOUL_STARTER = [
1262
+ 'You are a helpful, warm generalist. Answer first, explain second, and',
1263
+ 'keep replies short unless the question genuinely needs depth. Use',
1264
+ 'memory.remember for durable facts about the people you work with.',
1265
+ ].join('\n');
1266
+ // Shown when live model listing is unavailable (e.g. subscription tokens
1267
+ // cannot call the models endpoint, or the machine is offline).
1268
+ // Model ids that cannot serve /chat/completions and must not become the
1269
+ // default: embeddings, audio, images, moderation, and legacy completions.
1270
+ const NON_CHAT_MODEL_PATTERN = /embed|whisper|tts|audio|dall-e|image|moderation|realtime|transcribe|davinci|babbage|curie|(^|[-_])ada([-_]|$)/i;
1271
+ const KNOWN_CLAUDE_MODELS = [
1272
+ 'claude-opus-5',
1273
+ 'claude-sonnet-5',
1274
+ 'claude-haiku-4-5',
1275
+ 'claude-opus-4-6',
1276
+ 'claude-sonnet-4-6',
1277
+ ];
856
1278
  export const runSetup = async (command, streams, env = {}) => {
857
1279
  const cwd = readWorkingDirectory(env);
858
1280
  const processEnv = readProcessEnv(env);
859
- // Mirror resolveConfigPath's precedence (--config, then STRATUS_CONFIG /
860
- // STRATUSCLAW_CONFIG, then the default filename) so the file written here
861
- // is the one `stratus run` actually loads afterwards.
1281
+ // Config target: --config, then STRATUS_CONFIG / STRATUSCLAW_CONFIG, then
1282
+ // the global ~/.stratus/config.json — the file `stratus run` falls back to
1283
+ // from any directory, which is what makes setup a one-time step.
862
1284
  const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG)
863
1285
  ? 'STRATUS_CONFIG'
864
1286
  : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
865
1287
  ? 'STRATUSCLAW_CONFIG'
866
1288
  : undefined;
867
1289
  const envConfigPath = envConfigVar ? String(processEnv[envConfigVar]).trim() : undefined;
868
- const chosenPath = command.configPath ?? envConfigPath ?? DEFAULT_CONFIG_FILENAME;
869
- const configPath = path.resolve(cwd, chosenPath);
1290
+ const configPath = command.configPath
1291
+ ? path.resolve(cwd, command.configPath)
1292
+ : envConfigPath
1293
+ ? path.resolve(cwd, envConfigPath)
1294
+ : globalConfigPath(env);
870
1295
  // A path passed via --config is not auto-discovered by `stratus run`, so
871
- // suggested commands must carry it explicitly. Env-derived paths need no
872
- // flag because `run` reads the same variable.
1296
+ // suggested commands must carry it explicitly.
873
1297
  const runConfigFlag = command.configPath ? ` --config ${quoteShellArg(command.configPath)}` : '';
874
- const prompter = createSetupPrompter(streams, env);
1298
+ // Seed from what is already configured, so re-running setup edits instead
1299
+ // of clobbering.
1300
+ let existing = {};
875
1301
  try {
876
- writeLine(streams.stdout, 'Stratus Agent setup');
877
- writeLine(streams.stdout, 'This walkthrough writes a stratus.config.json so `stratus run` works out of the box.');
878
- writeLine(streams.stdout, 'Press Enter to accept the [default] for any question.');
879
- if (!command.configPath && envConfigVar) {
880
- writeLine(streams.stdout, `${envConfigVar} is set, so the config will be written to ${configPath}.`);
1302
+ existing = await loadConfigFile(configPath);
1303
+ }
1304
+ catch (error) {
1305
+ if (error.code !== 'ENOENT') {
1306
+ writeLine(streams.stderr, `Warning: could not read ${configPath} (${error instanceof Error ? error.message : String(error)}); starting fresh.`);
881
1307
  }
882
- writeLine(streams.stdout);
883
- if (await fileExists(configPath)) {
884
- const overwrite = await prompter.ask(`${configPath} already exists. Overwrite it? [y/N] `);
885
- if (!/^y(es)?$/i.test(overwrite)) {
886
- writeLine(streams.stdout, 'Keeping the existing config. Nothing was changed.');
887
- return 0;
1308
+ }
1309
+ const state = {
1310
+ provider: existing.provider ?? 'anthropic',
1311
+ ...(existing.model ? { model: existing.model } : {}),
1312
+ ...(existing.baseUrl ? { baseUrl: existing.baseUrl } : {}),
1313
+ ...(existing.apiKeyEnv ? { apiKeyEnv: existing.apiKeyEnv } : {}),
1314
+ ...(existing.systemPrompt ? { systemPrompt: existing.systemPrompt } : {}),
1315
+ ...(existing.soul ? { soulPath: existing.soul } : {}),
1316
+ ...(existing.fallbackModel ? { fallbackModel: existing.fallbackModel } : {}),
1317
+ // Pin an implicit fallback provider now, so a later default-provider
1318
+ // switch cannot silently change what the fallback means.
1319
+ ...(existing.fallbackModel || existing.fallbackProvider
1320
+ ? { fallbackProvider: existing.fallbackProvider ?? existing.provider ?? 'anthropic' }
1321
+ : {}),
1322
+ ...(existing.fallbackBaseUrl ? { fallbackBaseUrl: existing.fallbackBaseUrl } : {}),
1323
+ credentials: await loadCredentials(env),
1324
+ credentialsDirty: false,
1325
+ };
1326
+ const prompter = createSetupPrompter(streams, env);
1327
+ const defaultModelFor = (provider) => provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL;
1328
+ const defaultKeyEnvFor = (provider) => provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
1329
+ const credentialLabel = (credential) => credential.type === 'oauth_token' ? 'Claude subscription' : 'API key';
1330
+ const providerSignInStatus = (provider) => {
1331
+ const credential = state.credentials[provider];
1332
+ if (credential) {
1333
+ return `signed in (${credentialLabel(credential)})`;
1334
+ }
1335
+ if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
1336
+ return `using ${defaultKeyEnvFor(provider)} from your environment`;
1337
+ }
1338
+ return 'not signed in';
1339
+ };
1340
+ const providersSummary = () => {
1341
+ const parts = [];
1342
+ for (const provider of ['anthropic', 'openai']) {
1343
+ const credential = state.credentials[provider];
1344
+ if (credential) {
1345
+ parts.push(`${provider} (${credentialLabel(credential)})`);
1346
+ }
1347
+ else if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
1348
+ parts.push(`${provider} (env key)`);
888
1349
  }
889
- writeLine(streams.stdout);
890
1350
  }
891
- const providerAnswer = await prompter.ask('Which provider do you want to use?\n 1) openai — any OpenAI-compatible API (needs an API key)\n 2) demo — built-in provider, no key required\nChoose [1]: ');
892
- const provider = providerAnswer === '2' || /^demo$/i.test(providerAnswer) ? 'demo' : 'openai';
893
- // Exported STRATUS_*/STRATUSCLAW_* variables outrank the config file in
894
- // resolveRuntimeConfig, so any conflicting value would make the suggested
895
- // bare command behave differently from what setup just wrote. Detect each
896
- // conflict, warn about it, and add the matching flag so the printed
897
- // command wins regardless.
898
- const detectEnvOverride = (primary, legacy, chosen, flagName) => {
899
- const envVar = readNonEmptyString(processEnv[primary])
900
- ? primary
901
- : readNonEmptyString(processEnv[legacy])
902
- ? legacy
903
- : undefined;
904
- if (!envVar) {
905
- return undefined;
1351
+ if (parts.length === 0) {
1352
+ return state.provider === 'demo' ? 'demo — offline, no account' : 'none signed in yet';
1353
+ }
1354
+ if (state.provider === 'demo') {
1355
+ parts.push('default: demo');
1356
+ }
1357
+ return parts.join(' · ');
1358
+ };
1359
+ const modelsSummary = () => {
1360
+ if (state.provider === 'demo') {
1361
+ return 'demo (no model)';
1362
+ }
1363
+ const base = `default ${state.model ?? `${defaultModelFor(state.provider)} (default)`}`;
1364
+ return state.fallbackModel ? `${base} · fallback ${state.fallbackModel}` : `${base} · no fallback`;
1365
+ };
1366
+ const signInSummary = () => {
1367
+ if (state.provider === 'demo') {
1368
+ return 'no account needed';
1369
+ }
1370
+ const credential = state.credentials[state.provider];
1371
+ if (credential) {
1372
+ return credential.type === 'oauth_token'
1373
+ ? 'signed in with your Claude subscription'
1374
+ : 'signed in with an API key';
1375
+ }
1376
+ const keyEnv = state.apiKeyEnv ?? defaultKeyEnvFor(state.provider);
1377
+ if (readNonEmptyString(processEnv.STRATUS_API_KEY) ?? readNonEmptyString(processEnv[keyEnv])) {
1378
+ return `using ${readNonEmptyString(processEnv.STRATUS_API_KEY) ? 'STRATUS_API_KEY' : keyEnv} from your environment`;
1379
+ }
1380
+ return 'not signed in yet';
1381
+ };
1382
+ const agentSummary = () => {
1383
+ if (!state.soulPath) {
1384
+ return 'none — every run uses the built-in default agent';
1385
+ }
1386
+ return state.soulPath;
1387
+ };
1388
+ const storeCredential = (provider, credential) => {
1389
+ state.credentials[provider] = credential;
1390
+ state.credentialsDirty = true;
1391
+ };
1392
+ const signInAnthropic = async () => {
1393
+ const signedIn = state.credentials.anthropic !== undefined;
1394
+ const answer = await prompter.ask('How should Stratus connect to Claude?\n'
1395
+ + ' 1) Claude subscription (Pro/Max) — sign in through Claude Code, no per-token cost\n'
1396
+ + ' 2) Anthropic API key — pay per use (console.anthropic.com)\n'
1397
+ + ' 3) Skip for now\n'
1398
+ + (signedIn ? ' 4) Sign out\n' : '')
1399
+ + 'Choose [1]: ');
1400
+ if (signedIn && (answer === '4' || /^sign ?out$/i.test(answer))) {
1401
+ delete state.credentials.anthropic;
1402
+ state.credentialsDirty = true;
1403
+ writeLine(streams.stdout, 'Signed out of Anthropic.');
1404
+ return;
1405
+ }
1406
+ if (answer === '3' || /^skip$/i.test(answer)) {
1407
+ return;
1408
+ }
1409
+ if (answer === '2' || /^(api|key)/i.test(answer)) {
1410
+ const key = await prompter.askSecret('Paste your Anthropic API key (starts with sk-ant-, Enter to skip; input is hidden): ');
1411
+ if (!key) {
1412
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
1413
+ return;
906
1414
  }
907
- const envValue = String(processEnv[envVar]).trim();
908
- if (envValue === chosen) {
909
- return undefined;
1415
+ writeLine(streams.stdout, 'Checking the key against the Anthropic API…');
1416
+ // The key is verified against the configured endpoint and bound to
1417
+ // it, so a later provider switch (or an anthropic fallback) can never
1418
+ // send a proxy credential to the official endpoint.
1419
+ const verifyEndpoint = state.provider === 'anthropic' ? state.baseUrl : undefined;
1420
+ const binding = verifyEndpoint && verifyEndpoint.replace(/\/+$/, '') !== DEFAULT_ANTHROPIC_BASE_URL
1421
+ ? { baseUrl: verifyEndpoint }
1422
+ : {};
1423
+ const verdict = await verifyProviderKey('anthropic', key, verifyEndpoint, env.fetch ?? globalThis.fetch);
1424
+ if (verdict.status === 'ok') {
1425
+ storeCredential('anthropic', { type: 'api_key', value: key, ...binding });
1426
+ writeLine(streams.stdout, '✓ Key verified — you are signed in to Anthropic.');
910
1427
  }
911
- return {
912
- envVar,
913
- envValue,
914
- ...(flagName ? { flag: `${flagName} ${quoteShellArg(chosen)}` } : {}),
915
- };
916
- };
917
- const warnAboutEnvOverrides = (conflicts) => {
918
- for (const conflict of conflicts) {
919
- writeLine(streams.stdout, `Note: ${conflict.envVar}=${conflict.envValue} is exported and takes precedence over the config file (run \`unset ${conflict.envVar}\` to clear it).`);
1428
+ else if (verdict.status === 'rejected') {
1429
+ writeLine(streams.stdout, `✗ Anthropic rejected that key (${verdict.detail}). It was NOT saved — check console.anthropic.com and try again from this menu.`);
920
1430
  }
921
- if (conflicts.some((conflict) => conflict.flag)) {
922
- writeLine(streams.stdout, 'The suggested commands below include flags so they use what you just configured.');
1431
+ else {
1432
+ storeCredential('anthropic', { type: 'api_key', value: key, ...binding });
1433
+ 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.`);
923
1434
  }
924
- if (conflicts.length > 0) {
925
- writeLine(streams.stdout);
1435
+ return;
1436
+ }
1437
+ // Default: subscription sign-in via Claude Code.
1438
+ writeLine(streams.stdout, 'Your Claude Pro/Max subscription covers usage made through Claude Code.');
1439
+ writeLine(streams.stdout, 'In another terminal on this machine, run:');
1440
+ writeLine(streams.stdout, ' claude setup-token');
1441
+ writeLine(streams.stdout, '(requires Claude Code installed and signed in to your Claude account)');
1442
+ const token = await prompter.askSecret('Paste the setup token it prints (starts with sk-ant-oat, Enter to skip; input is hidden): ');
1443
+ if (!token) {
1444
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
1445
+ return;
1446
+ }
1447
+ storeCredential('anthropic', { type: 'oauth_token', value: token });
1448
+ writeLine(streams.stdout, '✓ Subscription token saved. It is verified on your first run.');
1449
+ };
1450
+ const signInOpenAI = async () => {
1451
+ const currentEndpoint = (state.provider === 'openai' ? state.baseUrl : undefined)
1452
+ ?? state.credentials.openai?.baseUrl
1453
+ ?? DEFAULT_OPENAI_BASE_URL;
1454
+ const baseUrlAnswer = await prompter.ask(`API base URL [${currentEndpoint}]: `);
1455
+ const chosenEndpoint = baseUrlAnswer || currentEndpoint;
1456
+ // state.baseUrl describes the DEFAULT provider's endpoint; a secondary
1457
+ // openai sign-in keeps its endpoint on the credential instead. The
1458
+ // change is committed only once a sign-in is accepted — a rejected key
1459
+ // must not leave a new endpoint paired with the old credential.
1460
+ const commitEndpoint = () => {
1461
+ if (state.provider === 'openai') {
1462
+ state.baseUrl = chosenEndpoint;
926
1463
  }
927
1464
  };
1465
+ const key = await prompter.askSecret('Paste your API key (Enter to skip; input is hidden): ');
1466
+ if (!key) {
1467
+ // Without a stored credential there is no old key the new endpoint
1468
+ // could be mispaired with.
1469
+ if (!state.credentials.openai) {
1470
+ commitEndpoint();
1471
+ }
1472
+ else if (chosenEndpoint !== currentEndpoint) {
1473
+ writeLine(streams.stdout, 'Endpoint left unchanged — paste a key for the new endpoint to switch to it.');
1474
+ }
1475
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
1476
+ return;
1477
+ }
1478
+ writeLine(streams.stdout, 'Checking the key…');
1479
+ // The endpoint travels with the credential, so this sign-in keeps
1480
+ // working even when another provider is the default.
1481
+ const endpoint = chosenEndpoint !== DEFAULT_OPENAI_BASE_URL ? { baseUrl: chosenEndpoint } : {};
1482
+ const verdict = await verifyProviderKey('openai', key, chosenEndpoint, env.fetch ?? globalThis.fetch);
1483
+ if (verdict.status === 'ok') {
1484
+ storeCredential('openai', { type: 'api_key', value: key, ...endpoint });
1485
+ commitEndpoint();
1486
+ writeLine(streams.stdout, '✓ Key verified — you are signed in.');
1487
+ }
1488
+ else if (verdict.status === 'rejected') {
1489
+ writeLine(streams.stdout, `✗ The API rejected that key (${verdict.detail}). It was NOT saved — try again from this menu.`);
1490
+ if (chosenEndpoint !== currentEndpoint) {
1491
+ writeLine(streams.stdout, 'The endpoint was left unchanged as well.');
1492
+ }
1493
+ }
1494
+ else {
1495
+ storeCredential('openai', { type: 'api_key', value: key, ...endpoint });
1496
+ commitEndpoint();
1497
+ writeLine(streams.stdout, `! Could not reach the API to verify (${verdict.detail}). Saved the key anyway — it will be checked on your first run.`);
1498
+ }
1499
+ };
1500
+ // Changing the default provider invalidates settings that were chosen
1501
+ // for the old one: the model and apiKeyEnv are cleared (defaults take
1502
+ // over), while the openai base URL is kept — it belongs to the openai
1503
+ // sign-in and still serves openai fallbacks. A soul that pins a provider
1504
+ // outranks the config at run time, so that earns a warning, not a reset.
1505
+ const switchDefaultProvider = async (next) => {
1506
+ if (state.provider === next) {
1507
+ return;
1508
+ }
1509
+ state.provider = next;
1510
+ delete state.model;
1511
+ delete state.apiKeyEnv;
1512
+ // state.baseUrl is the DEFAULT provider's endpoint; the old provider's
1513
+ // URL must not follow the switch. An openai default reseeds from the
1514
+ // credential's bound endpoint.
1515
+ delete state.baseUrl;
1516
+ if (next === 'openai' && state.credentials.openai?.baseUrl) {
1517
+ state.baseUrl = state.credentials.openai.baseUrl;
1518
+ }
1519
+ if (state.soulPath) {
1520
+ try {
1521
+ const soul = parseSoul(await readFile(state.soulPath, 'utf8'), { seed: state.soulPath });
1522
+ if (soul.provider && soul.provider !== next) {
1523
+ 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).`);
1524
+ }
1525
+ }
1526
+ catch {
1527
+ // A broken soul file surfaces when it is actually used.
1528
+ }
1529
+ }
1530
+ };
1531
+ // Whether a provider could actually serve a run right now, through any
1532
+ // credential source a real run would consider: the stored sign-in, the
1533
+ // generic STRATUS_API_KEY, a configured apiKeyEnv, or the provider's own
1534
+ // env var.
1535
+ const providerUsable = (provider) => {
928
1536
  if (provider === 'demo') {
929
- const conflicts = [
930
- detectEnvOverride('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER', provider, '--provider'),
931
- ].filter((conflict) => conflict !== undefined);
932
- const extraFlags = conflicts.map((conflict) => ` ${conflict.flag}`).join('');
933
- await writeFile(configPath, `${JSON.stringify({ provider: 'demo' }, null, 2)}\n`);
934
- writeLine(streams.stdout);
935
- writeLine(streams.stdout, `Wrote ${configPath}`);
1537
+ return true;
1538
+ }
1539
+ const keyEnvSelector = provider === state.provider
1540
+ ? readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
1541
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
1542
+ ?? state.apiKeyEnv
1543
+ : undefined;
1544
+ return state.credentials[provider] !== undefined
1545
+ || readNonEmptyString(processEnv.STRATUS_API_KEY) !== undefined
1546
+ || (keyEnvSelector ? readNonEmptyString(processEnv[String(keyEnvSelector)]) !== undefined : false)
1547
+ || readNonEmptyString(processEnv[defaultKeyEnvFor(provider)]) !== undefined;
1548
+ };
1549
+ // Signing in makes that provider the default only when the current
1550
+ // default cannot actually run (demo, or a provider with no usable key).
1551
+ const maybeSwitchDefault = async (provider) => {
1552
+ if (state.provider === 'demo' || (state.provider !== provider && !providerUsable(state.provider))) {
1553
+ await switchDefaultProvider(provider);
1554
+ }
1555
+ };
1556
+ const chooseProviders = async () => {
1557
+ const answer = await prompter.ask('Providers — sign in to one or more:\n'
1558
+ + ` 1) Claude (Anthropic) — ${providerSignInStatus('anthropic')}\n`
1559
+ + ` 2) OpenAI-compatible — ${providerSignInStatus('openai')}\n`
1560
+ + ' 3) Demo — built-in fake model, offline, no account\n'
1561
+ + ' 4) Back\n'
1562
+ + 'Choose [1]: ');
1563
+ if (answer === '4' || /^back$/i.test(answer)) {
1564
+ return;
1565
+ }
1566
+ if (answer === '3' || /^demo$/i.test(answer)) {
1567
+ await switchDefaultProvider('demo');
1568
+ writeLine(streams.stdout, 'Demo selected — no sign-in needed. Mention "echo" or "tool" in a prompt to see tool calls.');
1569
+ return;
1570
+ }
1571
+ if (answer === '2' || /^openai$/i.test(answer)) {
1572
+ await signInOpenAI();
1573
+ if (state.credentials.openai) {
1574
+ await maybeSwitchDefault('openai');
1575
+ }
1576
+ return;
1577
+ }
1578
+ await signInAnthropic();
1579
+ if (state.credentials.anthropic) {
1580
+ await maybeSwitchDefault('anthropic');
1581
+ }
1582
+ };
1583
+ // Every model the current sign-ins can actually reach, fetched live where
1584
+ // possible. Subscription tokens cannot call the models endpoint, so those
1585
+ // fall back to the known Claude lineup.
1586
+ const collectAvailableModels = async () => {
1587
+ const fetchImpl = env.fetch ?? globalThis.fetch;
1588
+ const models = [];
1589
+ for (const provider of ['anthropic', 'openai']) {
1590
+ // Discovery uses the credential a real run would use. STRATUS_API_KEY
1591
+ // and a configured apiKeyEnv authenticate the DEFAULT provider only —
1592
+ // a secondary provider relies on its own env var or stored sign-in,
1593
+ // never the default provider's secret.
1594
+ const envKey = (provider === state.provider
1595
+ ? readNonEmptyString(processEnv.STRATUS_API_KEY)
1596
+ ?? (state.apiKeyEnv ? readNonEmptyString(processEnv[state.apiKeyEnv]) : undefined)
1597
+ : undefined)
1598
+ ?? readNonEmptyString(processEnv[defaultKeyEnvFor(provider)]);
1599
+ const credential = envKey ? undefined : state.credentials[provider];
1600
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
1601
+ if (!apiKey && !credential) {
1602
+ continue;
1603
+ }
1604
+ if (provider === 'anthropic') {
1605
+ if (!apiKey || typeof fetchImpl !== 'function') {
1606
+ // Subscription tokens cannot call the models endpoint.
1607
+ models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
1608
+ continue;
1609
+ }
1610
+ // The same endpoint a real run uses: the stored key's bound URL is
1611
+ // authoritative, then a configured anthropic base URL (a proxy) —
1612
+ // never the official endpoint by accident.
1613
+ const anthropicRoot = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
1614
+ ?? (state.provider === 'anthropic' ? state.baseUrl : undefined)
1615
+ ?? DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/, '');
1616
+ try {
1617
+ const response = await fetchImpl(`${anthropicRoot}/v1/models?limit=100`, {
1618
+ headers: { 'x-api-key': String(apiKey), 'anthropic-version': '2023-06-01' },
1619
+ });
1620
+ const payload = await response.json();
1621
+ const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string');
1622
+ models.push(...(ids.length > 0 ? ids : KNOWN_CLAUDE_MODELS).map((id) => ({ provider, id })));
1623
+ }
1624
+ catch {
1625
+ models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
1626
+ }
1627
+ continue;
1628
+ }
1629
+ if (!apiKey || typeof fetchImpl !== 'function') {
1630
+ continue;
1631
+ }
1632
+ try {
1633
+ // A stored key's bound endpoint is authoritative, exactly as at run
1634
+ // time; only env-supplied keys follow the default provider's URL.
1635
+ const root = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
1636
+ ?? (state.provider === 'openai' ? state.baseUrl : undefined)
1637
+ ?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
1638
+ const response = await fetchImpl(`${root}/models`, {
1639
+ headers: { authorization: `Bearer ${String(apiKey)}` },
1640
+ });
1641
+ const payload = await response.json();
1642
+ const allIds = (payload.data ?? [])
1643
+ .map((entry) => entry.id)
1644
+ .filter((id) => typeof id === 'string');
1645
+ // Runs always call /chat/completions, so embedding, audio, image,
1646
+ // moderation, and legacy completion models would save a default
1647
+ // that cannot execute. If filtering leaves nothing (an exotic local
1648
+ // service), show everything rather than an empty menu.
1649
+ const chatIds = allIds.filter((id) => !NON_CHAT_MODEL_PATTERN.test(id));
1650
+ const ids = (chatIds.length > 0 ? chatIds : allIds).sort((a, b) => {
1651
+ const rank = (id) => (/^gpt/i.test(id) ? 0 : /^o\d/i.test(id) ? 1 : 2);
1652
+ return rank(a) - rank(b) || a.localeCompare(b);
1653
+ });
1654
+ models.push(...ids.map((id) => ({ provider, id })));
1655
+ }
1656
+ catch {
1657
+ // No reachable model list for this provider; skip it.
1658
+ }
1659
+ }
1660
+ return models;
1661
+ };
1662
+ const pickModel = async (kind) => {
1663
+ const available = await collectAvailableModels();
1664
+ if (available.length === 0) {
1665
+ writeLine(streams.stdout, 'No models available yet — sign in to a provider first (menu 1).');
1666
+ return;
1667
+ }
1668
+ const shown = available.slice(0, 30);
1669
+ const lines = shown.map((entry, index) => ` ${index + 1}) ${entry.id} — ${entry.provider}`);
1670
+ if (available.length > shown.length) {
1671
+ lines.push(` …and ${available.length - shown.length} more — type the model id instead.`);
1672
+ }
1673
+ const answer = await prompter.ask(`Available models:\n${lines.join('\n')}\nChoose a number, or type a model id [1]: `);
1674
+ let choice;
1675
+ if (!answer) {
1676
+ choice = shown[0];
1677
+ }
1678
+ else if (/^\d+$/.test(answer)) {
1679
+ choice = shown[Number(answer) - 1];
1680
+ if (!choice) {
1681
+ writeLine(streams.stdout, `Pick a number between 1 and ${shown.length}.`);
1682
+ return;
1683
+ }
1684
+ }
1685
+ else if (answer.includes(':')) {
1686
+ const [providerPart, ...idParts] = answer.split(':');
1687
+ const id = idParts.join(':').trim();
1688
+ if ((providerPart === 'anthropic' || providerPart === 'openai') && id) {
1689
+ choice = { provider: providerPart, id };
1690
+ }
1691
+ else {
1692
+ writeLine(streams.stdout, 'Use provider:model, e.g. anthropic:claude-opus-5.');
1693
+ return;
1694
+ }
1695
+ }
1696
+ else {
1697
+ // A typed id that appears in the collected list belongs to that
1698
+ // provider, wherever the default currently points.
1699
+ const listed = available.find((entry) => entry.id === answer);
1700
+ const inferred = listed?.provider
1701
+ ?? (state.provider !== 'demo' ? state.provider : available[0].provider);
1702
+ choice = { provider: inferred, id: answer };
1703
+ }
1704
+ if (!choice) {
1705
+ return;
1706
+ }
1707
+ if (kind === 'default') {
1708
+ await switchDefaultProvider(choice.provider);
1709
+ state.model = choice.id;
1710
+ writeLine(streams.stdout, `Default model set to ${choice.id} (${choice.provider}).`);
1711
+ // A soul's model pin outranks the config at run time, so a silent
1712
+ // mismatch here would make this choice a no-op.
1713
+ if (state.soulPath) {
1714
+ try {
1715
+ const soul = parseSoul(await readFile(state.soulPath, 'utf8'), { seed: state.soulPath });
1716
+ if (soul.model && soul.model !== choice.id
1717
+ && (soul.provider === undefined || soul.provider === choice.provider)) {
1718
+ 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).`);
1719
+ }
1720
+ }
1721
+ catch {
1722
+ // A broken soul file surfaces when it is actually used.
1723
+ }
1724
+ }
1725
+ }
1726
+ else {
1727
+ state.fallbackProvider = choice.provider;
1728
+ state.fallbackModel = choice.id;
1729
+ const openaiEndpoint = (state.provider === 'openai' ? state.baseUrl : undefined)
1730
+ ?? state.credentials.openai?.baseUrl;
1731
+ if (choice.provider === 'openai' && openaiEndpoint && openaiEndpoint !== DEFAULT_OPENAI_BASE_URL) {
1732
+ state.fallbackBaseUrl = openaiEndpoint;
1733
+ }
1734
+ else {
1735
+ delete state.fallbackBaseUrl;
1736
+ }
1737
+ if (choice.id === (state.model ?? defaultModelFor(state.provider)) && choice.provider === state.provider) {
1738
+ writeLine(streams.stdout, 'Note: the fallback matches the default model, so it will not add resilience.');
1739
+ }
1740
+ writeLine(streams.stdout, `Fallback model set to ${choice.id} (${choice.provider}) — used when the default model errors mid-run.`);
1741
+ }
1742
+ };
1743
+ const chooseModels = async () => {
1744
+ const answer = await prompter.ask(`Models — ${modelsSummary()}\n`
1745
+ + ' 1) Choose the default model\n'
1746
+ + ' 2) Choose a fallback model\n'
1747
+ + ' 3) Clear the fallback\n'
1748
+ + ' 4) Back\n'
1749
+ + 'Choose [1]: ');
1750
+ if (answer === '2') {
1751
+ await pickModel('fallback');
1752
+ return;
1753
+ }
1754
+ if (answer === '3') {
1755
+ delete state.fallbackModel;
1756
+ delete state.fallbackProvider;
1757
+ writeLine(streams.stdout, 'Fallback cleared.');
1758
+ return;
1759
+ }
1760
+ if (answer === '4' || /^back$/i.test(answer)) {
1761
+ return;
1762
+ }
1763
+ await pickModel('default');
1764
+ };
1765
+ const chooseAgent = async () => {
1766
+ const answer = await prompter.ask('Your default agent:\n'
1767
+ + ' 1) Create a new agent\n'
1768
+ + ' 2) Use an existing soul file\n'
1769
+ + ' 3) No default agent\n'
1770
+ + 'Choose [1]: ');
1771
+ if (answer === '3' || /^none$/i.test(answer)) {
1772
+ delete state.soulPath;
1773
+ writeLine(streams.stdout, 'Cleared — runs use the built-in default agent.');
1774
+ return;
1775
+ }
1776
+ if (answer === '2') {
1777
+ const soulAnswer = await prompter.ask('Path to the soul file: ');
1778
+ if (!soulAnswer) {
1779
+ return;
1780
+ }
1781
+ const resolved = path.resolve(cwd, soulAnswer);
1782
+ try {
1783
+ const soul = parseSoul(await readFile(resolved, 'utf8'), { seed: resolved });
1784
+ state.soulPath = resolved;
1785
+ writeLine(streams.stdout, `Loaded ${soul.agent.name} from ${resolved}.`);
1786
+ }
1787
+ catch (error) {
1788
+ writeLine(streams.stdout, `Could not load that soul file: ${error instanceof Error ? error.message : String(error)}`);
1789
+ }
1790
+ return;
1791
+ }
1792
+ const name = await prompter.ask('Name your agent (Enter to have one generated): ');
1793
+ const instructions = await prompter.ask('Describe their personality in a sentence or two (Enter for a starter you can edit later): ');
1794
+ const agent = defineAgent({
1795
+ ...(name ? { name } : {}),
1796
+ instructions: instructions || DEFAULT_SOUL_STARTER,
1797
+ });
1798
+ const soul = formatSoul({
1799
+ agent,
1800
+ ...(state.provider !== 'demo' ? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) } : {}),
1801
+ });
1802
+ const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
1803
+ await mkdir(path.dirname(soulPath), { recursive: true });
1804
+ await writeFile(soulPath, soul);
1805
+ state.soulPath = soulPath;
1806
+ writeLine(streams.stdout, `Say hello to ${agent.name}.`);
1807
+ writeLine(streams.stdout, `Their soul lives at ${soulPath} — edit it any time to change how they talk.`);
1808
+ };
1809
+ // Mirror the saved config's fallback so option 4 exercises the same
1810
+ // failover a real run would perform.
1811
+ const buildTestFallback = () => {
1812
+ if (!state.fallbackModel || state.provider === 'demo') {
1813
+ return undefined;
1814
+ }
1815
+ const fallbackProvider = (state.fallbackProvider ?? state.provider);
1816
+ const envKey = (fallbackProvider === state.provider
1817
+ ? readNonEmptyString(processEnv.STRATUS_API_KEY)
1818
+ : undefined)
1819
+ ?? readNonEmptyString(processEnv[defaultKeyEnvFor(fallbackProvider)]);
1820
+ const credential = envKey ? undefined : state.credentials[fallbackProvider];
1821
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
1822
+ const authToken = credential?.type === 'oauth_token' ? credential.value : undefined;
1823
+ if (!apiKey && !authToken) {
1824
+ return undefined;
1825
+ }
1826
+ return {
1827
+ provider: fallbackProvider,
1828
+ model: state.fallbackModel,
1829
+ ...(fallbackProvider === 'openai'
1830
+ ? {
1831
+ baseUrl: (credential?.type === 'api_key' ? credential.baseUrl : undefined)
1832
+ ?? state.fallbackBaseUrl
1833
+ ?? (state.provider === 'openai' ? state.baseUrl : undefined)
1834
+ ?? DEFAULT_OPENAI_BASE_URL,
1835
+ }
1836
+ : (() => {
1837
+ const url = (fallbackProvider === state.provider ? state.baseUrl : undefined)
1838
+ ?? (credential?.type === 'api_key' ? credential.baseUrl : undefined);
1839
+ return url ? { baseUrl: url } : {};
1840
+ })()),
1841
+ ...(apiKey ? { apiKey } : {}),
1842
+ ...(authToken ? { authToken } : {}),
1843
+ };
1844
+ };
1845
+ const buildTestRuntime = async () => {
1846
+ let soul;
1847
+ if (state.soulPath) {
1848
+ try {
1849
+ soul = parseSoul(await readFile(state.soulPath, 'utf8'), { seed: state.soulPath });
1850
+ }
1851
+ catch (error) {
1852
+ writeLine(streams.stdout, `Warning: could not load the soul file (${error instanceof Error ? error.message : String(error)}); testing without it.`);
1853
+ }
1854
+ }
1855
+ if (state.provider === 'demo') {
1856
+ return { provider: 'demo', ...(soul ? { soul } : {}) };
1857
+ }
1858
+ // Mirror resolveRuntimeConfig exactly: environment keys (including the
1859
+ // STRATUS_API_KEY_ENV selector) outrank the stored sign-in, and a
1860
+ // stored key's bound endpoint is authoritative — so the inline test
1861
+ // exercises precisely what a real run will use.
1862
+ const keyEnv = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
1863
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
1864
+ ?? state.apiKeyEnv
1865
+ ?? defaultKeyEnvFor(state.provider);
1866
+ const envKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
1867
+ ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
1868
+ ?? readNonEmptyString(processEnv[String(keyEnv)]);
1869
+ const credential = envKey ? undefined : state.credentials[state.provider];
1870
+ const boundUrl = credential?.type === 'api_key' ? credential.baseUrl : undefined;
1871
+ const model = state.model ?? defaultModelFor(state.provider);
1872
+ if (state.provider === 'anthropic') {
1873
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
1874
+ const authToken = credential?.type === 'oauth_token' ? credential.value : undefined;
1875
+ if (!apiKey && !authToken) {
1876
+ writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export ANTHROPIC_API_KEY).');
1877
+ return undefined;
1878
+ }
1879
+ const fallback = buildTestFallback();
1880
+ const anthropicUrl = boundUrl ?? state.baseUrl;
1881
+ return {
1882
+ provider: 'anthropic',
1883
+ model,
1884
+ ...(anthropicUrl ? { baseUrl: anthropicUrl } : {}),
1885
+ ...(apiKey ? { apiKey } : {}),
1886
+ ...(authToken ? { authToken } : {}),
1887
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
1888
+ ...(env.fetch ? { fetch: env.fetch } : {}),
1889
+ ...(soul ? { soul } : {}),
1890
+ ...(fallback ? { fallback } : {}),
1891
+ };
1892
+ }
1893
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
1894
+ if (!apiKey) {
1895
+ writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export OPENAI_API_KEY).');
1896
+ return undefined;
1897
+ }
1898
+ const fallback = buildTestFallback();
1899
+ return {
1900
+ provider: 'openai',
1901
+ model,
1902
+ baseUrl: boundUrl ?? state.baseUrl ?? DEFAULT_OPENAI_BASE_URL,
1903
+ apiKey,
1904
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
1905
+ ...(env.fetch ? { fetch: env.fetch } : {}),
1906
+ ...(soul ? { soul } : {}),
1907
+ ...(fallback ? { fallback } : {}),
1908
+ };
1909
+ };
1910
+ const testRun = async () => {
1911
+ const runtime = await buildTestRuntime();
1912
+ if (!runtime) {
1913
+ return;
1914
+ }
1915
+ writeLine(streams.stdout, `Running a quick hello (${formatRuntimeBanner(runtime).replace('Starting Stratus Agent local loop with ', '')})…`);
1916
+ try {
1917
+ const session = await runSingleLoop('Say hello and introduce yourself in one short sentence.', streams, { events: false, runtime, env });
1918
+ printSessionSummary(session, streams);
936
1919
  writeLine(streams.stdout);
937
- warnAboutEnvOverrides(conflicts);
938
- writeLine(streams.stdout, 'You are ready to go — no API key needed. Try:');
939
- writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag} "please use the echo tool"`);
940
- writeLine(streams.stdout, ' stratus dashboard');
941
- return 0;
942
1920
  }
943
- const model = (await prompter.ask(`Model [${DEFAULT_OPENAI_MODEL}]: `)) || DEFAULT_OPENAI_MODEL;
944
- const baseUrl = (await prompter.ask(`Base URL [${DEFAULT_OPENAI_BASE_URL}]: `)) || DEFAULT_OPENAI_BASE_URL;
945
- const apiKeyEnv = (await prompter.ask('Environment variable holding your API key [OPENAI_API_KEY]: ')) || 'OPENAI_API_KEY';
946
- const systemPrompt = await prompter.ask('System prompt (optional, Enter to skip): ');
947
- const config = { provider, model, baseUrl, apiKeyEnv };
948
- if (systemPrompt) {
949
- config.systemPrompt = systemPrompt;
1921
+ catch (error) {
1922
+ writeLine(streams.stdout, `Test run failed: ${error instanceof Error ? error.message : String(error)}`);
1923
+ }
1924
+ };
1925
+ const detectEnvOverride = (primary, legacy, chosen, flagName) => {
1926
+ const envVar = readNonEmptyString(processEnv[primary])
1927
+ ? primary
1928
+ : readNonEmptyString(processEnv[legacy])
1929
+ ? legacy
1930
+ : undefined;
1931
+ if (!envVar) {
1932
+ return undefined;
1933
+ }
1934
+ const envValue = String(processEnv[envVar]).trim();
1935
+ if (envValue === chosen) {
1936
+ return undefined;
950
1937
  }
1938
+ return {
1939
+ envVar,
1940
+ envValue,
1941
+ ...(flagName ? { flag: `${flagName} ${quoteShellArg(chosen)}` } : {}),
1942
+ };
1943
+ };
1944
+ const save = async () => {
1945
+ let shadowConfigFlag = '';
1946
+ const config = { provider: state.provider };
1947
+ if (state.provider !== 'demo') {
1948
+ config.model = state.model ?? defaultModelFor(state.provider);
1949
+ }
1950
+ if (state.provider === 'openai') {
1951
+ config.baseUrl = state.baseUrl ?? state.credentials.openai?.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
1952
+ }
1953
+ else if (state.provider === 'anthropic' && state.baseUrl) {
1954
+ // A configured anthropic endpoint (a proxy) must survive re-running
1955
+ // setup, or runs silently revert to the official endpoint.
1956
+ config.baseUrl = state.baseUrl;
1957
+ }
1958
+ if (state.apiKeyEnv) {
1959
+ config.apiKeyEnv = state.apiKeyEnv;
1960
+ }
1961
+ if (state.systemPrompt) {
1962
+ config.systemPrompt = state.systemPrompt;
1963
+ }
1964
+ if (state.soulPath) {
1965
+ config.soul = state.soulPath;
1966
+ }
1967
+ if (state.provider !== 'demo' && state.fallbackModel) {
1968
+ config.fallbackModel = state.fallbackModel;
1969
+ config.fallbackProvider = state.fallbackProvider ?? state.provider;
1970
+ if (config.fallbackProvider === 'openai' && state.fallbackBaseUrl) {
1971
+ config.fallbackBaseUrl = state.fallbackBaseUrl;
1972
+ }
1973
+ }
1974
+ await mkdir(path.dirname(configPath), { recursive: true });
951
1975
  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
1976
  writeLine(streams.stdout);
961
1977
  writeLine(streams.stdout, `Wrote ${configPath}`);
1978
+ // A project-local config in this directory outranks the global file for
1979
+ // bare runs started here — say so, and make the suggested command pick
1980
+ // the file that was just written.
1981
+ if (configPath === globalConfigPath(env)) {
1982
+ for (const shadow of [DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME]) {
1983
+ const shadowPath = path.join(cwd, shadow);
1984
+ try {
1985
+ await readFile(shadowPath, 'utf8');
1986
+ writeLine(streams.stdout, `Note: ${shadowPath} exists and takes precedence over the global config for runs started in this directory.`);
1987
+ writeLine(streams.stdout, 'The suggested commands below include --config so they use what you just saved.');
1988
+ shadowConfigFlag = ` --config ${quoteShellArg(configPath)}`;
1989
+ break;
1990
+ }
1991
+ catch (error) {
1992
+ if (error.code !== 'ENOENT') {
1993
+ throw error;
1994
+ }
1995
+ }
1996
+ }
1997
+ }
1998
+ if (state.credentialsDirty) {
1999
+ await saveCredentials(env, state.credentials);
2000
+ writeLine(streams.stdout, `Saved your sign-in to ${credentialsPath(env)} (readable only by you).`);
2001
+ }
962
2002
  writeLine(streams.stdout);
963
- warnAboutEnvOverrides(conflicts);
964
- // Readiness must mirror resolveRuntimeConfig's key lookup: a direct
965
- // STRATUS_API_KEY wins, then an exported *_API_KEY_ENV redirects which
966
- // variable is read, and only then does the config file's apiKeyEnv apply.
967
- const keyEnvOverrideVar = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
968
- ? 'STRATUS_API_KEY_ENV'
969
- : readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
970
- ? 'STRATUSCLAW_API_KEY_ENV'
971
- : undefined;
972
- const effectiveApiKeyEnv = keyEnvOverrideVar ? String(processEnv[keyEnvOverrideVar]).trim() : apiKeyEnv;
973
- const directKeyVar = readNonEmptyString(processEnv.STRATUS_API_KEY)
974
- ? 'STRATUS_API_KEY'
975
- : readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
976
- ? 'STRATUSCLAW_API_KEY'
977
- : undefined;
978
- if (keyEnvOverrideVar && effectiveApiKeyEnv !== apiKeyEnv) {
979
- writeLine(streams.stdout, `Note: ${keyEnvOverrideVar}=${effectiveApiKeyEnv} is exported and takes precedence over the config file's apiKeyEnv,`);
980
- writeLine(streams.stdout, `so \`stratus run\` will read ${effectiveApiKeyEnv}. Run \`unset ${keyEnvOverrideVar}\` to use ${apiKeyEnv} instead.`);
2003
+ // Exported STRATUS_* variables outrank the config file, so warn when one
2004
+ // would make `stratus run` behave differently from what was just saved.
2005
+ const conflicts = [
2006
+ detectEnvOverride('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER', state.provider, '--provider'),
2007
+ ...(state.provider !== 'demo'
2008
+ ? [detectEnvOverride('STRATUS_MODEL', 'STRATUSCLAW_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
2009
+ : []),
2010
+ ...(state.provider === 'openai'
2011
+ ? [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', state.baseUrl ?? DEFAULT_OPENAI_BASE_URL, '--base-url')]
2012
+ : [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', '')]),
2013
+ detectEnvOverride('STRATUS_SYSTEM_PROMPT', 'STRATUSCLAW_SYSTEM_PROMPT', state.systemPrompt ?? ''),
2014
+ ].filter((conflict) => conflict !== undefined);
2015
+ for (const conflict of conflicts) {
2016
+ writeLine(streams.stdout, `Note: ${conflict.envVar}=${conflict.envValue} is exported and takes precedence over the config file (run \`unset ${conflict.envVar}\` to clear it).`);
2017
+ }
2018
+ if (conflicts.some((conflict) => conflict.flag)) {
2019
+ writeLine(streams.stdout, 'The suggested commands below include flags so they use what you just configured.');
2020
+ }
2021
+ if (conflicts.length > 0) {
981
2022
  writeLine(streams.stdout);
982
2023
  }
983
- if (directKeyVar) {
984
- writeLine(streams.stdout, `${directKeyVar} is set in your environment — you are ready to go. Try:`);
2024
+ const extraFlags = conflicts.flatMap((conflict) => (conflict.flag ? [` ${conflict.flag}`] : [])).join('');
2025
+ if (state.provider === 'demo') {
2026
+ writeLine(streams.stdout, 'You are ready to go — no account needed. Try:');
2027
+ }
2028
+ else if (readNonEmptyString(processEnv.STRATUS_API_KEY)) {
2029
+ writeLine(streams.stdout, 'STRATUS_API_KEY is exported and takes precedence over your saved sign-in. You are ready to go. Try:');
985
2030
  }
986
- else if (processEnv[effectiveApiKeyEnv]) {
987
- writeLine(streams.stdout, `${effectiveApiKeyEnv} is set in your environment — you are ready to go. Try:`);
2031
+ else if (state.credentials[state.provider]) {
2032
+ writeLine(streams.stdout, `You are ${signInSummary()} — ready to go. Try:`);
988
2033
  }
989
2034
  else {
990
- writeLine(streams.stdout, `${effectiveApiKeyEnv} is NOT set in your environment yet. Set it first:`);
991
- writeLine(streams.stdout, ` export ${effectiveApiKeyEnv}=your-key`);
992
- writeLine(streams.stdout);
993
- writeLine(streams.stdout, 'Then try:');
2035
+ const keyEnv = state.apiKeyEnv ?? defaultKeyEnvFor(state.provider);
2036
+ if (readNonEmptyString(processEnv[keyEnv])) {
2037
+ writeLine(streams.stdout, `${keyEnv} is set in your environment — you are ready to go. Try:`);
2038
+ }
2039
+ else {
2040
+ writeLine(streams.stdout, 'You are NOT signed in yet — re-run `stratus setup` and pick option 1, or:');
2041
+ writeLine(streams.stdout, ` export ${keyEnv}=your-key`);
2042
+ writeLine(streams.stdout);
2043
+ writeLine(streams.stdout, 'Then try:');
2044
+ }
994
2045
  }
995
- writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag} "say hello"`);
2046
+ writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag}${shadowConfigFlag} "say hello"`);
996
2047
  writeLine(streams.stdout, ' stratus dashboard');
2048
+ };
2049
+ try {
2050
+ writeLine(streams.stdout, 'Stratus Agent setup');
2051
+ writeLine(streams.stdout, 'Pick a provider, sign in, and create your agent — all from this menu.');
2052
+ if (envConfigVar && !command.configPath) {
2053
+ writeLine(streams.stdout, `${envConfigVar} is set, so the config will be written to ${configPath}.`);
2054
+ }
2055
+ while (true) {
2056
+ writeLine(streams.stdout);
2057
+ const choice = await prompter.ask(` 1) Providers ${providersSummary()}\n`
2058
+ + ` 2) Models ${modelsSummary()}\n`
2059
+ + ` 3) Agent ${agentSummary()}\n`
2060
+ + ' 4) Test run say hello with the current settings\n'
2061
+ + ' 5) Save & finish\n'
2062
+ + 'Choose [1-5]: ');
2063
+ if (prompter.isClosed() && choice === '') {
2064
+ break;
2065
+ }
2066
+ if (choice === '1') {
2067
+ await chooseProviders();
2068
+ }
2069
+ else if (choice === '2') {
2070
+ await chooseModels();
2071
+ }
2072
+ else if (choice === '3') {
2073
+ await chooseAgent();
2074
+ }
2075
+ else if (choice === '4') {
2076
+ await testRun();
2077
+ }
2078
+ else if (choice === '5' || /^(save|done|finish|q(uit)?)$/i.test(choice)) {
2079
+ break;
2080
+ }
2081
+ else {
2082
+ writeLine(streams.stdout, 'Pick a number between 1 and 5.');
2083
+ }
2084
+ }
2085
+ await save();
997
2086
  return 0;
998
2087
  }
999
2088
  finally {
@@ -1009,6 +2098,10 @@ export const runAgentNew = (command, streams) => {
1009
2098
  writeLine(streams.stdout, JSON.stringify(agent, null, 2));
1010
2099
  return 0;
1011
2100
  }
2101
+ if (command.format === 'soul') {
2102
+ streams.stdout.write(formatSoul({ agent, provider: 'anthropic', model: DEFAULT_ANTHROPIC_MODEL }));
2103
+ return 0;
2104
+ }
1012
2105
  writeLine(streams.stdout, `Say hello to ${agent.name}.`);
1013
2106
  writeLine(streams.stdout);
1014
2107
  writeLine(streams.stdout, ` id ${agent.id}`);
@@ -1017,7 +2110,11 @@ export const runAgentNew = (command, streams) => {
1017
2110
  writeLine(streams.stdout, ` soul ${agent.instructions}`);
1018
2111
  }
1019
2112
  writeLine(streams.stdout);
1020
- writeLine(streams.stdout, 'Definition (save this — soul files are coming in #13):');
2113
+ writeLine(streams.stdout, 'Save this as a soul file and run it:');
2114
+ writeLine(streams.stdout, ` stratus agent new --name ${quoteShellArg(agent.name)}${command.instructions ? ` --instructions ${quoteShellArg(command.instructions)}` : ''} --format soul > my-agent.md`);
2115
+ writeLine(streams.stdout, ' stratus run --soul my-agent.md "hello"');
2116
+ writeLine(streams.stdout);
2117
+ writeLine(streams.stdout, 'Definition (JSON):');
1021
2118
  writeLine(streams.stdout, JSON.stringify(agent, null, 2));
1022
2119
  return 0;
1023
2120
  };
@@ -1082,7 +2179,9 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
1082
2179
  if (command.format === 'json') {
1083
2180
  writeLine(streams.stdout, JSON.stringify({
1084
2181
  provider: runtime.provider,
1085
- session,
2182
+ // Replay state (Claude's raw thinking turns) stays in the stored
2183
+ // session but never in user-facing output.
2184
+ session: redactAnthropicRawTurns(session),
1086
2185
  }, null, 2));
1087
2186
  return 0;
1088
2187
  }