@stratusagent/cli 0.11.2 → 0.11.3

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.
Files changed (158) hide show
  1. package/README.md +13 -7
  2. package/dist/approvals.d.ts +87 -0
  3. package/dist/approvals.d.ts.map +1 -0
  4. package/dist/approvals.js +315 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/commands/agent-new.d.ts +22 -0
  7. package/dist/commands/agent-new.d.ts.map +1 -0
  8. package/dist/commands/agent-new.js +154 -0
  9. package/dist/commands/agent-new.js.map +1 -0
  10. package/dist/commands/agents.d.ts +4 -0
  11. package/dist/commands/agents.d.ts.map +1 -0
  12. package/dist/commands/agents.js +90 -0
  13. package/dist/commands/agents.js.map +1 -0
  14. package/dist/commands/chat.d.ts +4 -0
  15. package/dist/commands/chat.d.ts.map +1 -0
  16. package/dist/commands/chat.js +224 -0
  17. package/dist/commands/chat.js.map +1 -0
  18. package/dist/commands/credential.d.ts +22 -0
  19. package/dist/commands/credential.d.ts.map +1 -0
  20. package/dist/commands/credential.js +93 -0
  21. package/dist/commands/credential.js.map +1 -0
  22. package/dist/commands/dashboard.d.ts +14 -0
  23. package/dist/commands/dashboard.d.ts.map +1 -0
  24. package/dist/commands/dashboard.js +195 -0
  25. package/dist/commands/dashboard.js.map +1 -0
  26. package/dist/commands/doctor.d.ts +40 -0
  27. package/dist/commands/doctor.d.ts.map +1 -0
  28. package/dist/commands/doctor.js +357 -0
  29. package/dist/commands/doctor.js.map +1 -0
  30. package/dist/commands/grants.d.ts +19 -0
  31. package/dist/commands/grants.d.ts.map +1 -0
  32. package/dist/commands/grants.js +128 -0
  33. package/dist/commands/grants.js.map +1 -0
  34. package/dist/commands/logs.d.ts +9 -0
  35. package/dist/commands/logs.d.ts.map +1 -0
  36. package/dist/commands/logs.js +58 -0
  37. package/dist/commands/logs.js.map +1 -0
  38. package/dist/commands/memory.d.ts +20 -0
  39. package/dist/commands/memory.d.ts.map +1 -0
  40. package/dist/commands/memory.js +110 -0
  41. package/dist/commands/memory.js.map +1 -0
  42. package/dist/commands/plugins.d.ts +91 -0
  43. package/dist/commands/plugins.d.ts.map +1 -0
  44. package/dist/commands/plugins.js +359 -0
  45. package/dist/commands/plugins.js.map +1 -0
  46. package/dist/commands/restart.d.ts +4 -0
  47. package/dist/commands/restart.d.ts.map +1 -0
  48. package/dist/commands/restart.js +25 -0
  49. package/dist/commands/restart.js.map +1 -0
  50. package/dist/commands/schedules.d.ts +11 -0
  51. package/dist/commands/schedules.d.ts.map +1 -0
  52. package/dist/commands/schedules.js +50 -0
  53. package/dist/commands/schedules.js.map +1 -0
  54. package/dist/commands/serve.d.ts +4 -0
  55. package/dist/commands/serve.d.ts.map +1 -0
  56. package/dist/commands/serve.js +612 -0
  57. package/dist/commands/serve.js.map +1 -0
  58. package/dist/commands/service.d.ts +9 -0
  59. package/dist/commands/service.d.ts.map +1 -0
  60. package/dist/commands/service.js +97 -0
  61. package/dist/commands/service.js.map +1 -0
  62. package/dist/commands/session.d.ts +9 -0
  63. package/dist/commands/session.d.ts.map +1 -0
  64. package/dist/commands/session.js +25 -0
  65. package/dist/commands/session.js.map +1 -0
  66. package/dist/commands/setup.d.ts +5 -0
  67. package/dist/commands/setup.d.ts.map +1 -0
  68. package/dist/commands/setup.js +2412 -0
  69. package/dist/commands/setup.js.map +1 -0
  70. package/dist/commands/skills.d.ts +15 -0
  71. package/dist/commands/skills.d.ts.map +1 -0
  72. package/dist/commands/skills.js +272 -0
  73. package/dist/commands/skills.js.map +1 -0
  74. package/dist/commands/template.d.ts +4 -0
  75. package/dist/commands/template.d.ts.map +1 -0
  76. package/dist/commands/template.js +575 -0
  77. package/dist/commands/template.js.map +1 -0
  78. package/dist/commands/update.d.ts +21 -0
  79. package/dist/commands/update.d.ts.map +1 -0
  80. package/dist/commands/update.js +291 -0
  81. package/dist/commands/update.js.map +1 -0
  82. package/dist/daemon.d.ts +74 -0
  83. package/dist/daemon.d.ts.map +1 -0
  84. package/dist/daemon.js +192 -0
  85. package/dist/daemon.js.map +1 -0
  86. package/dist/environment.d.ts +108 -0
  87. package/dist/environment.d.ts.map +1 -0
  88. package/dist/environment.js +2 -0
  89. package/dist/environment.js.map +1 -0
  90. package/dist/events.d.ts +17 -0
  91. package/dist/events.d.ts.map +1 -0
  92. package/dist/events.js +87 -0
  93. package/dist/events.js.map +1 -0
  94. package/dist/help.d.ts +2 -0
  95. package/dist/help.d.ts.map +1 -0
  96. package/dist/help.js +266 -0
  97. package/dist/help.js.map +1 -0
  98. package/dist/index.d.ts +31 -624
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +38 -6981
  101. package/dist/index.js.map +1 -1
  102. package/dist/io.d.ts +16 -0
  103. package/dist/io.d.ts.map +1 -0
  104. package/dist/io.js +45 -0
  105. package/dist/io.js.map +1 -0
  106. package/dist/loaders.d.ts +31 -0
  107. package/dist/loaders.d.ts.map +1 -0
  108. package/dist/loaders.js +56 -0
  109. package/dist/loaders.js.map +1 -0
  110. package/dist/main.d.ts +8 -0
  111. package/dist/main.d.ts.map +1 -0
  112. package/dist/main.js +214 -0
  113. package/dist/main.js.map +1 -0
  114. package/dist/npm.d.ts +66 -0
  115. package/dist/npm.d.ts.map +1 -0
  116. package/dist/npm.js +201 -0
  117. package/dist/npm.js.map +1 -0
  118. package/dist/parse.d.ts +231 -0
  119. package/dist/parse.d.ts.map +1 -0
  120. package/dist/parse.js +1082 -0
  121. package/dist/parse.js.map +1 -0
  122. package/dist/plugin-catalog.d.ts +35 -0
  123. package/dist/plugin-catalog.d.ts.map +1 -0
  124. package/dist/plugin-catalog.js +58 -0
  125. package/dist/plugin-catalog.js.map +1 -0
  126. package/dist/preflight.d.ts +60 -0
  127. package/dist/preflight.d.ts.map +1 -0
  128. package/dist/preflight.js +102 -0
  129. package/dist/preflight.js.map +1 -0
  130. package/dist/prompter.d.ts +84 -0
  131. package/dist/prompter.d.ts.map +1 -0
  132. package/dist/prompter.js +347 -0
  133. package/dist/prompter.js.map +1 -0
  134. package/dist/roster.d.ts +57 -0
  135. package/dist/roster.d.ts.map +1 -0
  136. package/dist/roster.js +78 -0
  137. package/dist/roster.js.map +1 -0
  138. package/dist/runtime.d.ts +59 -0
  139. package/dist/runtime.d.ts.map +1 -0
  140. package/dist/runtime.js +266 -0
  141. package/dist/runtime.js.map +1 -0
  142. package/dist/slack.d.ts +26 -0
  143. package/dist/slack.d.ts.map +1 -0
  144. package/dist/slack.js +113 -0
  145. package/dist/slack.js.map +1 -0
  146. package/dist/source.d.ts +24 -0
  147. package/dist/source.d.ts.map +1 -0
  148. package/dist/source.js +80 -0
  149. package/dist/source.js.map +1 -0
  150. package/dist/supervisor.d.ts +69 -0
  151. package/dist/supervisor.d.ts.map +1 -0
  152. package/dist/supervisor.js +218 -0
  153. package/dist/supervisor.js.map +1 -0
  154. package/dist/trusted-config.d.ts +71 -0
  155. package/dist/trusted-config.d.ts.map +1 -0
  156. package/dist/trusted-config.js +118 -0
  157. package/dist/trusted-config.js.map +1 -0
  158. package/package.json +25 -23
@@ -0,0 +1,2412 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { preflightPlugin, readPluginManifest, PluginConfigError, PluginManifestError, } from '@stratusagent/plugins';
4
+ import { DEFAULT_ANTHROPIC_MODEL } from '@stratusagent/provider-anthropic';
5
+ import { DEFAULT_CODEX_MODEL } from '@stratusagent/provider-codex';
6
+ import { formatSoul, parseSoul } from '@stratusagent/agents';
7
+ import { claimSoulFile, collectAvailableModels as collectModels, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, globalConfigPath, loadChannelCredentials, loadCredentials, loadRosterSouls, loadSoulFile, readNonEmptyString, readProcessEnv, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, saveChannelCredentials, saveConfigFile, saveCredentials, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, workspacesDirPath, } from '@stratusagent/state';
8
+ import { installService, readServiceStatus, servicePlatform, serviceUnitPath, uninstallService, } from "../service.js";
9
+ import { classifyApprovalReach, unattendedReachParts } from "../approvals.js";
10
+ import { serviceEnvFor } from "../daemon.js";
11
+ import { writeLine } from "../io.js";
12
+ import { packageInstalled } from "../loaders.js";
13
+ import { isPackageName, isInstallableSpecifier, defaultPackageInstaller } from "../npm.js";
14
+ import { PLUGIN_MARKETPLACE_URL, FIRST_PARTY_CAPABILITY_PACKAGES } from "../plugin-catalog.js";
15
+ import { menuPrefixWidth, quoteShellArg, stratusHeaderLines, createSetupPrompter, } from "../prompter.js";
16
+ import { soulGrantsTool } from "../roster.js";
17
+ import { runSingleLoop, printSessionSummary, formatRuntimeBanner } from "../runtime.js";
18
+ import { verifySlackBotToken, verifySlackAppToken, slackAppManifest } from "../slack.js";
19
+ export const DEFAULT_SOUL_STARTER = [
20
+ 'You are a helpful, warm generalist. Answer first, explain second, and',
21
+ 'keep replies short unless the question genuinely needs depth. Use',
22
+ 'memory.remember for durable facts about the people you work with, and',
23
+ 'memory.recall to look up what you know when a conversation calls for it.',
24
+ ].join('\n');
25
+ export const runSetup = async (command, streams, env = {}) => {
26
+ const cwd = readWorkingDirectory(env);
27
+ const processEnv = readProcessEnv(env);
28
+ // Config target: --config, then STRATUS_CONFIG, then the global
29
+ // ~/.stratus/config.json — the file `stratus run` falls back to from any
30
+ // directory, which is what makes setup a one-time step.
31
+ const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG) ? 'STRATUS_CONFIG' : undefined;
32
+ const envConfigPath = envConfigVar ? String(processEnv.STRATUS_CONFIG).trim() : undefined;
33
+ const configPath = command.configPath
34
+ ? path.resolve(cwd, command.configPath)
35
+ : envConfigPath
36
+ ? path.resolve(cwd, envConfigPath)
37
+ : globalConfigPath(env);
38
+ // A path passed via --config is not auto-discovered by `stratus run`, so
39
+ // suggested commands must carry it explicitly.
40
+ const runConfigFlag = command.configPath ? ` --config ${quoteShellArg(command.configPath)}` : '';
41
+ // Set once save() detects a project config shadowing the global one.
42
+ // Suggested commands must carry it too, or they read a different config
43
+ // than the one just written — for `serve` that means a different roster,
44
+ // and Slack apps stored here would be skipped as having no agent.
45
+ let shadowConfigFlag = '';
46
+ const serveCommand = () => `stratus serve${runConfigFlag}${shadowConfigFlag}`;
47
+ // Seed from what is already configured, so re-running setup edits instead
48
+ // of clobbering.
49
+ let existing = {};
50
+ try {
51
+ existing = await loadConfigFile(configPath);
52
+ }
53
+ catch (error) {
54
+ if (error.code !== 'ENOENT') {
55
+ writeLine(streams.stderr, `Warning: could not read ${configPath} (${error instanceof Error ? error.message : String(error)}); starting fresh.`);
56
+ }
57
+ }
58
+ // Interactive mode is a screen-based interface: every menu clears and
59
+ // redraws under a fixed header, and status lines printed between menus
60
+ // are carried onto the next screen (dimmed) so nothing is missed.
61
+ const interactive = env.setupInput === undefined && process.stdin.isTTY === true;
62
+ const baseStreams = streams;
63
+ const recentNotices = [];
64
+ if (interactive) {
65
+ streams = {
66
+ stderr: baseStreams.stderr,
67
+ stdout: {
68
+ write(chunk) {
69
+ for (const raw of String(chunk).split('\n')) {
70
+ const line = raw.trimEnd();
71
+ if (line.trim().length > 0) {
72
+ recentNotices.push(line);
73
+ if (recentNotices.length > 8) {
74
+ recentNotices.shift();
75
+ }
76
+ }
77
+ }
78
+ return baseStreams.stdout.write(chunk);
79
+ },
80
+ },
81
+ };
82
+ }
83
+ const state = {
84
+ provider: existing.provider ?? 'anthropic',
85
+ ...(existing.model ? { model: existing.model } : {}),
86
+ ...(existing.baseUrl ? { baseUrl: existing.baseUrl } : {}),
87
+ ...(existing.apiKeyEnv ? { apiKeyEnv: existing.apiKeyEnv } : {}),
88
+ ...(existing.systemPrompt ? { systemPrompt: existing.systemPrompt } : {}),
89
+ ...(existing.soul ? { soulPath: existing.soul } : {}),
90
+ ...(existing.fallbackModel ? { fallbackModel: existing.fallbackModel } : {}),
91
+ // Pin an implicit fallback provider now, so a later default-provider
92
+ // switch cannot silently change what the fallback means.
93
+ ...(existing.fallbackModel || existing.fallbackProvider
94
+ ? { fallbackProvider: existing.fallbackProvider ?? existing.provider ?? 'anthropic' }
95
+ : {}),
96
+ ...(existing.fallbackBaseUrl ? { fallbackBaseUrl: existing.fallbackBaseUrl } : {}),
97
+ ...(existing.vision !== undefined ? { vision: existing.vision } : {}),
98
+ ...(existing.promptCache !== undefined ? { promptCache: existing.promptCache } : {}),
99
+ ...(existing.promptCacheTtl !== undefined ? { promptCacheTtl: existing.promptCacheTtl } : {}),
100
+ ...(existing.plugins !== undefined ? { plugins: existing.plugins } : {}),
101
+ ...(existing.approvals !== undefined ? { approvals: existing.approvals } : {}),
102
+ ...(existing.api !== undefined ? { api: existing.api } : {}),
103
+ ...(existing.principals !== undefined ? { principals: existing.principals } : {}),
104
+ credentials: await loadCredentials(env),
105
+ credentialsDirty: false,
106
+ channels: await loadChannelCredentials(env),
107
+ channelsDirty: false,
108
+ // On by default: setup's whole promise is that you finish it and the
109
+ // agents are working. An always-on runtime you have to remember to
110
+ // start is not always-on, and every Slack app configured here stays
111
+ // silent until stratusd is up. An existing install keeps ITS login
112
+ // setting, though — rerunning setup and pressing save must not quietly
113
+ // undo a deliberate `service install --no-login`.
114
+ service: await (async () => {
115
+ const status = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
116
+ // An unknown answer (a broken user bus, a timed-out query) must not
117
+ // read as "they chose --no-login" — that would disable a service on
118
+ // the next save because of a transient failure.
119
+ return {
120
+ install: true,
121
+ runAtLogin: status?.installed && status.runAtLogin !== undefined ? status.runAtLogin : true,
122
+ };
123
+ })(),
124
+ };
125
+ const prompter = createSetupPrompter(baseStreams, env, {
126
+ header: stratusHeaderLines,
127
+ consumeNotices: () => recentNotices.splice(0),
128
+ });
129
+ const defaultModelFor = (provider) => provider === 'openai'
130
+ ? DEFAULT_OPENAI_MODEL
131
+ : provider === 'codex'
132
+ ? DEFAULT_CODEX_MODEL
133
+ : DEFAULT_ANTHROPIC_MODEL;
134
+ // Widened to CliProviderName only so setup can ask about the provider it
135
+ // currently has selected, `demo` included; the rule itself is the shared one.
136
+ const defaultKeyEnvFor = (provider) => defaultApiKeyEnvName(provider === 'demo' ? 'anthropic' : provider);
137
+ const credentialLabel = (provider, credential) => credential.type === 'oauth_token'
138
+ ? (provider === 'codex' ? 'ChatGPT sign-in' : 'Claude subscription')
139
+ : 'API key';
140
+ const providerSignInStatus = (provider) => {
141
+ const credential = state.credentials[provider];
142
+ if (credential) {
143
+ return `signed in (${credentialLabel(provider, credential)})`;
144
+ }
145
+ if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
146
+ return `using ${defaultKeyEnvFor(provider)} from your environment`;
147
+ }
148
+ return 'not signed in';
149
+ };
150
+ const providersSummary = () => {
151
+ const parts = [];
152
+ for (const provider of CREDENTIAL_PROVIDER_NAMES) {
153
+ const credential = state.credentials[provider];
154
+ if (credential) {
155
+ parts.push(`${provider} (${credentialLabel(provider, credential)})`);
156
+ }
157
+ else if (readNonEmptyString(processEnv[defaultKeyEnvFor(provider)])) {
158
+ parts.push(`${provider} (env key)`);
159
+ }
160
+ }
161
+ if (parts.length === 0) {
162
+ return state.provider === 'demo' ? 'demo — offline, no account' : 'none signed in yet';
163
+ }
164
+ if (state.provider === 'demo') {
165
+ parts.push('default: demo');
166
+ }
167
+ return parts.join(' · ');
168
+ };
169
+ const modelsSummary = () => {
170
+ if (state.provider === 'demo') {
171
+ return 'demo (no model)';
172
+ }
173
+ const base = `default ${state.model ?? `${defaultModelFor(state.provider)} (default)`}`;
174
+ return state.fallbackModel ? `${base} · fallback ${state.fallbackModel}` : `${base} · no fallback`;
175
+ };
176
+ const signInSummary = () => {
177
+ if (state.provider === 'demo') {
178
+ return 'no account needed';
179
+ }
180
+ const credential = state.credentials[state.provider];
181
+ if (credential) {
182
+ return credential.type === 'oauth_token'
183
+ ? (state.provider === 'codex'
184
+ ? 'using your ChatGPT (codex login) sign-in'
185
+ : 'signed in with your Claude subscription')
186
+ : 'signed in with an API key';
187
+ }
188
+ const keyEnv = state.apiKeyEnv ?? defaultKeyEnvFor(state.provider);
189
+ if (readNonEmptyString(processEnv.STRATUS_API_KEY) ?? readNonEmptyString(processEnv[keyEnv])) {
190
+ return `using ${readNonEmptyString(processEnv.STRATUS_API_KEY) ? 'STRATUS_API_KEY' : keyEnv} from your environment`;
191
+ }
192
+ return 'not signed in yet';
193
+ };
194
+ const agentSummary = () => {
195
+ if (!state.soulPath) {
196
+ return 'none — every run uses the built-in default agent';
197
+ }
198
+ return state.soulPath;
199
+ };
200
+ const channelsSummary = () => {
201
+ const connected = Object.keys(state.channels.slack ?? {}).length;
202
+ if (connected === 0) {
203
+ return 'Slack: not connected';
204
+ }
205
+ return `Slack: ${connected} agent${connected === 1 ? '' : 's'} connected`;
206
+ };
207
+ const storeCredential = (provider, credential) => {
208
+ state.credentials[provider] = credential;
209
+ state.credentialsDirty = true;
210
+ };
211
+ const signInAnthropic = async () => {
212
+ const signedIn = state.credentials.anthropic !== undefined;
213
+ const answer = await prompter.select('How should Stratus connect to Claude?', [
214
+ 'Claude subscription (Pro/Max) — sign in through Claude Code, no per-token cost',
215
+ 'Anthropic API key — pay per use (console.anthropic.com)',
216
+ 'Skip for now',
217
+ ...(signedIn ? ['Sign out'] : []),
218
+ ]);
219
+ if (signedIn && answer.kind === 'index' && answer.index === 3) {
220
+ delete state.credentials.anthropic;
221
+ state.credentialsDirty = true;
222
+ writeLine(streams.stdout, 'Signed out of Anthropic.');
223
+ return;
224
+ }
225
+ if (answer.kind !== 'index' || answer.index === 2) {
226
+ return;
227
+ }
228
+ if (answer.index === 1) {
229
+ const key = await prompter.askSecret('Paste your Anthropic API key (starts with sk-ant-, Enter to skip; input is hidden): ');
230
+ if (!key) {
231
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
232
+ return;
233
+ }
234
+ writeLine(streams.stdout, 'Checking the key against the Anthropic API…');
235
+ // The key is verified against the configured endpoint and bound to
236
+ // it, so a later provider switch (or an anthropic fallback) can never
237
+ // send a proxy credential to the official endpoint.
238
+ const verifyEndpoint = state.provider === 'anthropic' ? state.baseUrl : undefined;
239
+ const binding = verifyEndpoint && verifyEndpoint.replace(/\/+$/, '') !== DEFAULT_ANTHROPIC_BASE_URL
240
+ ? { baseUrl: verifyEndpoint }
241
+ : {};
242
+ const verdict = await verifyProviderKey('anthropic', key, verifyEndpoint, env.fetch ?? globalThis.fetch);
243
+ if (verdict.status === 'ok') {
244
+ storeCredential('anthropic', { type: 'api_key', value: key, ...binding });
245
+ writeLine(streams.stdout, '✓ Key verified — you are signed in to Anthropic.');
246
+ }
247
+ else if (verdict.status === 'rejected') {
248
+ writeLine(streams.stdout, `✗ Anthropic rejected that key (${verdict.detail}). It was NOT saved — check console.anthropic.com and try again from this menu.`);
249
+ }
250
+ else {
251
+ storeCredential('anthropic', { type: 'api_key', value: key, ...binding });
252
+ 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.`);
253
+ }
254
+ return;
255
+ }
256
+ // Default: subscription sign-in via Claude Code.
257
+ writeLine(streams.stdout, 'Your Claude Pro/Max subscription covers usage made through Claude Code.');
258
+ writeLine(streams.stdout, 'In another terminal on this machine, run:');
259
+ writeLine(streams.stdout, ' claude setup-token');
260
+ writeLine(streams.stdout, '(requires Claude Code installed and signed in to your Claude account)');
261
+ const token = await prompter.askSecret('Paste the setup token it prints (starts with sk-ant-oat, Enter to skip; input is hidden): ');
262
+ if (!token) {
263
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
264
+ return;
265
+ }
266
+ storeCredential('anthropic', { type: 'oauth_token', value: token });
267
+ writeLine(streams.stdout, '✓ Subscription token saved. It is verified on your first run.');
268
+ };
269
+ const signInOpenAI = async () => {
270
+ const currentEndpoint = (state.provider === 'openai' ? state.baseUrl : undefined)
271
+ ?? state.credentials.openai?.baseUrl
272
+ ?? DEFAULT_OPENAI_BASE_URL;
273
+ const baseUrlAnswer = await prompter.ask(`API base URL [${currentEndpoint}]: `);
274
+ const chosenEndpoint = baseUrlAnswer || currentEndpoint;
275
+ // state.baseUrl describes the DEFAULT provider's endpoint; a secondary
276
+ // openai sign-in keeps its endpoint on the credential instead. The
277
+ // change is committed only once a sign-in is accepted — a rejected key
278
+ // must not leave a new endpoint paired with the old credential.
279
+ const commitEndpoint = () => {
280
+ if (state.provider === 'openai') {
281
+ state.baseUrl = chosenEndpoint;
282
+ }
283
+ };
284
+ const key = await prompter.askSecret('Paste your API key (Enter to skip; input is hidden): ');
285
+ if (!key) {
286
+ // Without a stored credential there is no old key the new endpoint
287
+ // could be mispaired with.
288
+ if (!state.credentials.openai) {
289
+ commitEndpoint();
290
+ }
291
+ else if (chosenEndpoint !== currentEndpoint) {
292
+ writeLine(streams.stdout, 'Endpoint left unchanged — paste a key for the new endpoint to switch to it.');
293
+ }
294
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
295
+ return;
296
+ }
297
+ writeLine(streams.stdout, 'Checking the key…');
298
+ // The endpoint travels with the credential, so this sign-in keeps
299
+ // working even when another provider is the default.
300
+ const endpoint = chosenEndpoint !== DEFAULT_OPENAI_BASE_URL ? { baseUrl: chosenEndpoint } : {};
301
+ const verdict = await verifyProviderKey('openai', key, chosenEndpoint, env.fetch ?? globalThis.fetch);
302
+ if (verdict.status === 'ok') {
303
+ storeCredential('openai', { type: 'api_key', value: key, ...endpoint });
304
+ commitEndpoint();
305
+ writeLine(streams.stdout, '✓ Key verified — you are signed in.');
306
+ }
307
+ else if (verdict.status === 'rejected') {
308
+ writeLine(streams.stdout, `✗ The API rejected that key (${verdict.detail}). It was NOT saved — try again from this menu.`);
309
+ if (chosenEndpoint !== currentEndpoint) {
310
+ writeLine(streams.stdout, 'The endpoint was left unchanged as well.');
311
+ }
312
+ }
313
+ else {
314
+ storeCredential('openai', { type: 'api_key', value: key, ...endpoint });
315
+ commitEndpoint();
316
+ writeLine(streams.stdout, `! Could not reach the API to verify (${verdict.detail}). Saved the key anyway — it will be checked on your first run.`);
317
+ }
318
+ };
319
+ const signInCodex = async () => {
320
+ const signedIn = state.credentials.codex !== undefined;
321
+ const answer = await prompter.select('How should Stratus connect to Codex?', [
322
+ 'ChatGPT subscription — uses this machine\'s `codex login` sign-in, no per-token cost',
323
+ 'OpenAI API key — pay per use (platform.openai.com)',
324
+ 'Skip for now',
325
+ ...(signedIn ? ['Sign out'] : []),
326
+ ]);
327
+ if (signedIn && answer.kind === 'index' && answer.index === 3) {
328
+ delete state.credentials.codex;
329
+ state.credentialsDirty = true;
330
+ writeLine(streams.stdout, 'Signed out of Codex. (A `codex login` sign-in, if any, stays with codex itself — run `codex logout` to clear it.)');
331
+ return;
332
+ }
333
+ if (answer.kind !== 'index' || answer.index === 2) {
334
+ return;
335
+ }
336
+ if (answer.index === 1) {
337
+ const key = await prompter.askSecret('Paste your OpenAI API key (Enter to skip; input is hidden): ');
338
+ if (!key) {
339
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
340
+ return;
341
+ }
342
+ writeLine(streams.stdout, 'Checking the key against the OpenAI API…');
343
+ const verdict = await verifyProviderKey('codex', key, undefined, env.fetch ?? globalThis.fetch);
344
+ if (verdict.status === 'ok') {
345
+ storeCredential('codex', { type: 'api_key', value: key });
346
+ writeLine(streams.stdout, '✓ Key verified — Codex runs will bill this OpenAI API key.');
347
+ }
348
+ else if (verdict.status === 'rejected') {
349
+ writeLine(streams.stdout, `✗ OpenAI rejected that key (${verdict.detail}). It was NOT saved — check platform.openai.com and try again from this menu.`);
350
+ }
351
+ else {
352
+ storeCredential('codex', { type: 'api_key', value: key });
353
+ writeLine(streams.stdout, `! Could not reach the OpenAI API to verify (${verdict.detail}). Saved the key anyway — it will be checked on your first run.`);
354
+ }
355
+ return;
356
+ }
357
+ // Default: the machine's own ChatGPT sign-in. Codex keeps those tokens
358
+ // in its own auth store; Stratus records only that this machine uses
359
+ // it, so nothing secret is written here.
360
+ writeLine(streams.stdout, 'Your ChatGPT plan covers usage made through Codex.');
361
+ writeLine(streams.stdout, 'If you have not signed in yet, run this in another terminal on this machine:');
362
+ writeLine(streams.stdout, ' codex login');
363
+ writeLine(streams.stdout, '(requires the Codex CLI: npm install -g @openai/codex)');
364
+ const confirmed = await prompter.select('Use this machine\'s codex sign-in?', ['Yes — codex is (or will be) signed in here', 'Skip for now']);
365
+ if (confirmed.kind !== 'index' || confirmed.index !== 0) {
366
+ writeLine(streams.stdout, 'Skipped — you can sign in any time by re-running this menu.');
367
+ return;
368
+ }
369
+ storeCredential('codex', { type: 'oauth_token', value: 'chatgpt' });
370
+ writeLine(streams.stdout, '✓ Recorded — Codex runs use this machine\'s ChatGPT sign-in. It is verified on your first run.');
371
+ };
372
+ // Changing the default provider invalidates settings that were chosen
373
+ // for the old one: the model and apiKeyEnv are cleared (defaults take
374
+ // over), while the openai base URL is kept — it belongs to the openai
375
+ // sign-in and still serves openai fallbacks. A soul that pins a provider
376
+ // outranks the config at run time, so that earns a warning, not a reset.
377
+ const switchDefaultProvider = async (next) => {
378
+ if (state.provider === next) {
379
+ return;
380
+ }
381
+ state.provider = next;
382
+ delete state.model;
383
+ delete state.apiKeyEnv;
384
+ // state.baseUrl is the DEFAULT provider's endpoint; the old provider's
385
+ // URL must not follow the switch. An openai default reseeds from the
386
+ // credential's bound endpoint.
387
+ delete state.baseUrl;
388
+ if (next === 'openai' && state.credentials.openai?.baseUrl) {
389
+ state.baseUrl = state.credentials.openai.baseUrl;
390
+ }
391
+ if (state.soulPath) {
392
+ try {
393
+ const soul = parseSoul(await readFile(state.soulPath, 'utf8'), { seed: state.soulPath });
394
+ if (soul.provider && soul.provider !== next) {
395
+ 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).`);
396
+ }
397
+ }
398
+ catch {
399
+ // A broken soul file surfaces when it is actually used.
400
+ }
401
+ }
402
+ };
403
+ // Whether a provider could actually serve a run right now, through any
404
+ // credential source a real run would consider: the stored sign-in, the
405
+ // generic STRATUS_API_KEY, a configured apiKeyEnv, or the provider's own
406
+ // env var.
407
+ const providerUsable = (provider) => {
408
+ if (provider === 'demo') {
409
+ return true;
410
+ }
411
+ const keyEnvSelector = provider === state.provider
412
+ ? readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
413
+ ?? state.apiKeyEnv
414
+ : undefined;
415
+ return state.credentials[provider] !== undefined
416
+ || readNonEmptyString(processEnv.STRATUS_API_KEY) !== undefined
417
+ || (keyEnvSelector ? readNonEmptyString(processEnv[String(keyEnvSelector)]) !== undefined : false)
418
+ || readNonEmptyString(processEnv[defaultKeyEnvFor(provider)]) !== undefined;
419
+ };
420
+ // Signing in makes that provider the default only when the current
421
+ // default cannot actually run (demo, or a provider with no usable key).
422
+ const maybeSwitchDefault = async (provider) => {
423
+ if (state.provider === 'demo' || (state.provider !== provider && !providerUsable(state.provider))) {
424
+ await switchDefaultProvider(provider);
425
+ }
426
+ };
427
+ const chooseProviders = async () => {
428
+ const answer = await prompter.select('Providers — sign in to one or more:', [
429
+ `Claude (Anthropic) — ${providerSignInStatus('anthropic')}`,
430
+ `OpenAI-compatible — ${providerSignInStatus('openai')}`,
431
+ `Codex (ChatGPT) — ${providerSignInStatus('codex')}`,
432
+ 'Demo — built-in fake model, offline, no account',
433
+ 'Back',
434
+ ]);
435
+ if (answer.kind !== 'index' || answer.index === 4) {
436
+ return;
437
+ }
438
+ if (answer.index === 3) {
439
+ await switchDefaultProvider('demo');
440
+ writeLine(streams.stdout, 'Demo selected — no sign-in needed. Mention "echo" or "tool" in a prompt to see tool calls.');
441
+ return;
442
+ }
443
+ if (answer.index === 2) {
444
+ await signInCodex();
445
+ if (state.credentials.codex) {
446
+ await maybeSwitchDefault('codex');
447
+ }
448
+ return;
449
+ }
450
+ if (answer.index === 1) {
451
+ await signInOpenAI();
452
+ if (state.credentials.openai) {
453
+ await maybeSwitchDefault('openai');
454
+ }
455
+ return;
456
+ }
457
+ await signInAnthropic();
458
+ if (state.credentials.anthropic) {
459
+ await maybeSwitchDefault('anthropic');
460
+ }
461
+ };
462
+ // Every model the current sign-ins can actually reach. The rule lives in
463
+ // @stratusagent/state because the control API answers the same question —
464
+ // setup passes the selection it is *holding* rather than the saved one, so
465
+ // a key pasted a moment ago is already in play.
466
+ const collectAvailableModels = () => collectModels({
467
+ provider: state.provider,
468
+ ...(state.baseUrl !== undefined ? { baseUrl: state.baseUrl } : {}),
469
+ ...(state.apiKeyEnv !== undefined ? { apiKeyEnv: state.apiKeyEnv } : {}),
470
+ credentials: state.credentials,
471
+ }, env);
472
+ const pickModel = async (kind) => {
473
+ const available = await collectAvailableModels();
474
+ if (available.length === 0) {
475
+ writeLine(streams.stdout, 'No models available yet — sign in to a provider first (menu 1).');
476
+ return;
477
+ }
478
+ const shown = available.slice(0, 30);
479
+ const labels = shown.map((entry) => `${entry.id} — ${entry.provider}`);
480
+ const typeItOption = labels.length;
481
+ labels.push('Type a model id…');
482
+ const footnote = available.length > shown.length
483
+ ? ` …and ${available.length - shown.length} more — pick "Type a model id…" to name one.`
484
+ : undefined;
485
+ const answer = await prompter.select('Available models:', labels, {
486
+ allowText: true,
487
+ ...(footnote ? { footnote } : {}),
488
+ });
489
+ if (answer.kind === 'back') {
490
+ return;
491
+ }
492
+ const parseTyped = (typed) => {
493
+ if (typed.includes(':')) {
494
+ const [providerPart, ...idParts] = typed.split(':');
495
+ const id = idParts.join(':').trim();
496
+ if ((providerPart === 'anthropic' || providerPart === 'openai' || providerPart === 'codex') && id) {
497
+ return { provider: providerPart, id };
498
+ }
499
+ writeLine(streams.stdout, 'Use provider:model, e.g. anthropic:claude-opus-5 or codex:gpt-5.5.');
500
+ return undefined;
501
+ }
502
+ // A typed id that appears in the collected list belongs to that
503
+ // provider, wherever the default currently points.
504
+ const listed = available.find((entry) => entry.id === typed);
505
+ const inferred = listed?.provider
506
+ ?? (state.provider !== 'demo' ? state.provider : available[0].provider);
507
+ return { provider: inferred, id: typed };
508
+ };
509
+ let choice;
510
+ if (answer.kind === 'text') {
511
+ choice = parseTyped(answer.text);
512
+ }
513
+ else if (answer.index === typeItOption) {
514
+ const typed = await prompter.ask('Model id (or provider:model): ');
515
+ choice = typed ? parseTyped(typed) : undefined;
516
+ }
517
+ else {
518
+ choice = shown[answer.index];
519
+ }
520
+ if (!choice) {
521
+ return;
522
+ }
523
+ if (kind === 'default') {
524
+ await switchDefaultProvider(choice.provider);
525
+ state.model = choice.id;
526
+ writeLine(streams.stdout, `Default model set to ${choice.id} (${choice.provider}).`);
527
+ // A soul's model pin outranks the config at run time, so a silent
528
+ // mismatch here would make this choice a no-op.
529
+ if (state.soulPath) {
530
+ try {
531
+ const soul = parseSoul(await readFile(state.soulPath, 'utf8'), { seed: state.soulPath });
532
+ if (soul.model && soul.model !== choice.id
533
+ && (soul.provider === undefined || soul.provider === choice.provider)) {
534
+ 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).`);
535
+ }
536
+ }
537
+ catch {
538
+ // A broken soul file surfaces when it is actually used.
539
+ }
540
+ }
541
+ }
542
+ else {
543
+ state.fallbackProvider = choice.provider;
544
+ state.fallbackModel = choice.id;
545
+ const openaiEndpoint = (state.provider === 'openai' ? state.baseUrl : undefined)
546
+ ?? state.credentials.openai?.baseUrl;
547
+ if (choice.provider === 'openai' && openaiEndpoint && openaiEndpoint !== DEFAULT_OPENAI_BASE_URL) {
548
+ state.fallbackBaseUrl = openaiEndpoint;
549
+ }
550
+ else {
551
+ delete state.fallbackBaseUrl;
552
+ }
553
+ if (choice.id === (state.model ?? defaultModelFor(state.provider)) && choice.provider === state.provider) {
554
+ writeLine(streams.stdout, 'Note: the fallback matches the default model, so it will not add resilience.');
555
+ }
556
+ writeLine(streams.stdout, `Fallback model set to ${choice.id} (${choice.provider}) — used when the default model errors mid-run.`);
557
+ }
558
+ };
559
+ const chooseModels = async () => {
560
+ const answer = await prompter.select(`Models — ${modelsSummary()}`, [
561
+ 'Choose the default model',
562
+ 'Choose a fallback model',
563
+ 'Clear the fallback',
564
+ 'Back',
565
+ ]);
566
+ if (answer.kind !== 'index' || answer.index === 3) {
567
+ return;
568
+ }
569
+ if (answer.index === 1) {
570
+ await pickModel('fallback');
571
+ return;
572
+ }
573
+ if (answer.index === 2) {
574
+ delete state.fallbackModel;
575
+ delete state.fallbackProvider;
576
+ writeLine(streams.stdout, 'Fallback cleared.');
577
+ return;
578
+ }
579
+ await pickModel('default');
580
+ };
581
+ const chooseAgent = async () => {
582
+ const answer = await prompter.select('Your default agent:', [
583
+ 'Create a new agent',
584
+ 'Use an existing soul file',
585
+ 'No default agent',
586
+ ]);
587
+ if (answer.kind !== 'index') {
588
+ return;
589
+ }
590
+ if (answer.index === 2) {
591
+ delete state.soulPath;
592
+ writeLine(streams.stdout, 'Cleared — runs use the built-in default agent.');
593
+ return;
594
+ }
595
+ if (answer.index === 1) {
596
+ const soulAnswer = await prompter.ask('Path to the soul file: ');
597
+ if (!soulAnswer) {
598
+ return;
599
+ }
600
+ const resolved = path.resolve(cwd, soulAnswer);
601
+ try {
602
+ const soul = parseSoul(await readFile(resolved, 'utf8'), { seed: resolved });
603
+ state.soulPath = resolved;
604
+ writeLine(streams.stdout, `Loaded ${soul.agent.name} from ${resolved}.`);
605
+ }
606
+ catch (error) {
607
+ writeLine(streams.stdout, `Could not load that soul file: ${error instanceof Error ? error.message : String(error)}`);
608
+ }
609
+ return;
610
+ }
611
+ const name = await prompter.ask('Name your agent (Enter to have one generated): ');
612
+ const instructions = await prompter.ask('Describe their personality in a sentence or two (Enter for a starter you can edit later): ');
613
+ const persona = instructions || DEFAULT_SOUL_STARTER;
614
+ const pin = state.provider !== 'demo'
615
+ ? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) }
616
+ : {};
617
+ const claimed = await claimSoulFile(env, { ...(name ? { name } : {}), instructions: persona }, (agent) => formatSoul({ agent, ...pin }), (message) => writeLine(streams.stdout, message));
618
+ const { agent, soulPath } = claimed;
619
+ state.soulPath = soulPath;
620
+ writeLine(streams.stdout, `Say hello to ${agent.name}.`);
621
+ writeLine(streams.stdout, `Their soul lives at ${soulPath} — edit it any time to change how they talk.`);
622
+ };
623
+ // Mirror the saved config's fallback so option 4 exercises the same
624
+ // failover a real run would perform.
625
+ const buildTestFallback = () => {
626
+ if (!state.fallbackModel || state.provider === 'demo') {
627
+ return undefined;
628
+ }
629
+ const fallbackProvider = (state.fallbackProvider ?? state.provider);
630
+ const envKey = (fallbackProvider === state.provider
631
+ ? readNonEmptyString(processEnv.STRATUS_API_KEY)
632
+ : undefined)
633
+ ?? readNonEmptyString(processEnv[defaultKeyEnvFor(fallbackProvider)]);
634
+ const candidate = envKey ? undefined : state.credentials[fallbackProvider];
635
+ // A codex fallback consumes no endpoint URL, so a stored key bound to
636
+ // one cannot serve it — the same skip resolveRuntimeConfig performs.
637
+ const credential = fallbackProvider === 'codex' && candidate?.type === 'api_key' && candidate.baseUrl !== undefined
638
+ ? undefined
639
+ : candidate;
640
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
641
+ // A codex oauth entry is the subscription marker, not a token to send.
642
+ const codexSubscription = fallbackProvider === 'codex' && !apiKey && credential?.type === 'oauth_token';
643
+ const authToken = fallbackProvider !== 'codex' && credential?.type === 'oauth_token' ? credential.value : undefined;
644
+ if (!apiKey && !authToken && !codexSubscription) {
645
+ return undefined;
646
+ }
647
+ return {
648
+ provider: fallbackProvider,
649
+ model: state.fallbackModel,
650
+ ...(fallbackProvider === 'openai'
651
+ ? {
652
+ baseUrl: (credential?.type === 'api_key' ? credential.baseUrl : undefined)
653
+ ?? state.fallbackBaseUrl
654
+ ?? (state.provider === 'openai' ? state.baseUrl : undefined)
655
+ ?? DEFAULT_OPENAI_BASE_URL,
656
+ }
657
+ // The codex harness owns its endpoints; only anthropic can carry one.
658
+ : fallbackProvider === 'codex'
659
+ ? {}
660
+ : (() => {
661
+ const url = (fallbackProvider === state.provider ? state.baseUrl : undefined)
662
+ ?? (credential?.type === 'api_key' ? credential.baseUrl : undefined);
663
+ return url ? { baseUrl: url } : {};
664
+ })()),
665
+ ...(apiKey ? { apiKey } : {}),
666
+ ...(authToken ? { authToken } : {}),
667
+ ...(codexSubscription ? { codexSubscription: true } : {}),
668
+ };
669
+ };
670
+ const buildTestRuntime = async () => {
671
+ let soul;
672
+ if (state.soulPath) {
673
+ try {
674
+ soul = parseSoul(await readFile(state.soulPath, 'utf8'), { seed: state.soulPath });
675
+ }
676
+ catch (error) {
677
+ writeLine(streams.stdout, `Warning: could not load the soul file (${error instanceof Error ? error.message : String(error)}); testing without it.`);
678
+ }
679
+ }
680
+ if (state.provider === 'demo') {
681
+ return { provider: 'demo', ...(soul ? { soul } : {}) };
682
+ }
683
+ // Mirror resolveRuntimeConfig exactly: environment keys (including the
684
+ // STRATUS_API_KEY_ENV selector) outrank the stored sign-in, and a
685
+ // stored key's bound endpoint is authoritative — so the inline test
686
+ // exercises precisely what a real run will use.
687
+ const keyEnv = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
688
+ ?? state.apiKeyEnv
689
+ ?? defaultKeyEnvFor(state.provider);
690
+ const envKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
691
+ ?? readNonEmptyString(processEnv[String(keyEnv)]);
692
+ const credential = envKey ? undefined : state.credentials[state.provider];
693
+ const boundUrl = credential?.type === 'api_key' ? credential.baseUrl : undefined;
694
+ const model = state.model ?? defaultModelFor(state.provider);
695
+ if (state.provider === 'anthropic') {
696
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
697
+ const authToken = credential?.type === 'oauth_token' ? credential.value : undefined;
698
+ if (!apiKey && !authToken) {
699
+ writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export ANTHROPIC_API_KEY).');
700
+ return undefined;
701
+ }
702
+ const fallback = buildTestFallback();
703
+ const anthropicUrl = boundUrl ?? state.baseUrl;
704
+ return {
705
+ provider: 'anthropic',
706
+ model,
707
+ ...(anthropicUrl ? { baseUrl: anthropicUrl } : {}),
708
+ ...(apiKey ? { apiKey } : {}),
709
+ ...(authToken ? { authToken } : {}),
710
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
711
+ ...(env.fetch ? { fetch: env.fetch } : {}),
712
+ ...(soul ? { soul } : {}),
713
+ ...(fallback ? { fallback } : {}),
714
+ };
715
+ }
716
+ if (state.provider === 'codex') {
717
+ // The harness owns its endpoints: a key bound to one cannot be
718
+ // honored on codex — resolveRuntimeConfig refuses this outright, and
719
+ // the inline test mirrors it rather than quietly unbinding the key.
720
+ if (boundUrl !== undefined) {
721
+ writeLine(streams.stdout, `Your saved codex key is bound to ${boundUrl}, and codex does not use a custom base URL. Store the key without one to run on codex.`);
722
+ return undefined;
723
+ }
724
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
725
+ // The subscription marker means the machine's own codex sign-in
726
+ // serves the run — nothing more to resolve here.
727
+ if (!apiKey && credential?.type !== 'oauth_token') {
728
+ writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export CODEX_API_KEY).');
729
+ return undefined;
730
+ }
731
+ const fallback = buildTestFallback();
732
+ return {
733
+ provider: 'codex',
734
+ model,
735
+ ...(apiKey ? { apiKey } : {}),
736
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
737
+ ...(env.fetch ? { fetch: env.fetch } : {}),
738
+ ...(soul ? { soul } : {}),
739
+ ...(fallback ? { fallback } : {}),
740
+ };
741
+ }
742
+ const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
743
+ if (!apiKey) {
744
+ writeLine(streams.stdout, 'You are not signed in yet — pick option 1 first (or export OPENAI_API_KEY).');
745
+ return undefined;
746
+ }
747
+ const fallback = buildTestFallback();
748
+ return {
749
+ provider: 'openai',
750
+ model,
751
+ baseUrl: boundUrl ?? state.baseUrl ?? DEFAULT_OPENAI_BASE_URL,
752
+ apiKey,
753
+ ...(state.systemPrompt ? { systemPrompt: state.systemPrompt } : {}),
754
+ ...(env.fetch ? { fetch: env.fetch } : {}),
755
+ ...(soul ? { soul } : {}),
756
+ ...(fallback ? { fallback } : {}),
757
+ };
758
+ };
759
+ /**
760
+ * Walks an agent through connecting their own Slack app: show the
761
+ * manifest to paste, take both tokens, verify each against Slack, and
762
+ * store them under the agent's id. Tokens are keyed by a roster agent
763
+ * picked from a list rather than typed, so the id can never drift from
764
+ * the roster — the mismatch that otherwise surfaces only as a startup
765
+ * warning about an agent the gateway cannot find.
766
+ */
767
+ const connectSlackAgent = async (agentId, agentName) => {
768
+ writeLine(streams.stdout);
769
+ writeLine(streams.stdout, `Create a Slack app for ${agentName}:`);
770
+ writeLine(streams.stdout, ' 1. Open https://api.slack.com/apps → Create New App → From a manifest');
771
+ writeLine(streams.stdout, ' 2. Pick your workspace, then paste this manifest:');
772
+ writeLine(streams.stdout);
773
+ for (const line of slackAppManifest(agentName).split('\n')) {
774
+ writeLine(streams.stdout, ` ${line}`);
775
+ }
776
+ writeLine(streams.stdout);
777
+ writeLine(streams.stdout, ' 3. Create the app, then Basic Information → App-Level Tokens →');
778
+ writeLine(streams.stdout, ' Generate a token with the connections:write scope (xapp-…)');
779
+ writeLine(streams.stdout, ' 4. Install App → copy the Bot User OAuth Token (xoxb-…)');
780
+ writeLine(streams.stdout, ` 5. Basic Information → Display Information → upload ${agentName}'s avatar`);
781
+ writeLine(streams.stdout);
782
+ const appToken = await prompter.askSecret('Paste the app-level token (xapp-…, Enter to cancel; input is hidden): ');
783
+ if (!appToken) {
784
+ writeLine(streams.stdout, 'Cancelled — nothing was saved.');
785
+ return;
786
+ }
787
+ if (!appToken.startsWith('xapp-')) {
788
+ writeLine(streams.stdout, '✗ That does not look like an app-level token (they start with xapp-). Nothing was saved.');
789
+ return;
790
+ }
791
+ const botToken = await prompter.askSecret('Paste the bot user OAuth token (xoxb-…, Enter to cancel; input is hidden): ');
792
+ if (!botToken) {
793
+ writeLine(streams.stdout, 'Cancelled — nothing was saved.');
794
+ return;
795
+ }
796
+ if (!botToken.startsWith('xoxb-')) {
797
+ writeLine(streams.stdout, '✗ That does not look like a bot token (they start with xoxb-). Nothing was saved.');
798
+ return;
799
+ }
800
+ writeLine(streams.stdout, 'Checking the tokens with Slack…');
801
+ const fetchImpl = env.fetch ?? globalThis.fetch;
802
+ const bot = await verifySlackBotToken(botToken, fetchImpl);
803
+ if (bot.status === 'rejected') {
804
+ writeLine(streams.stdout, `✗ Slack rejected the bot token (${bot.detail}). Nothing was saved — reinstall the app and copy the token again.`);
805
+ return;
806
+ }
807
+ const app = await verifySlackAppToken(appToken, fetchImpl);
808
+ if (app.status === 'rejected') {
809
+ writeLine(streams.stdout, `✗ Slack rejected the app-level token (${app.detail}). Nothing was saved — check it has the connections:write scope.`);
810
+ return;
811
+ }
812
+ const slack = { ...(state.channels.slack ?? {}) };
813
+ slack[agentId] = { appToken, botToken };
814
+ state.channels = { ...state.channels, slack };
815
+ state.channelsDirty = true;
816
+ if (bot.status === 'ok' && app.status === 'ok') {
817
+ const where = bot.identity.teamName ? ` in ${bot.identity.teamName}` : '';
818
+ const who = bot.identity.botUserId ? ` (bot ${bot.identity.botUserId})` : '';
819
+ writeLine(streams.stdout, `✓ Verified — ${agentName} is connected to Slack${where}${who}.`);
820
+ }
821
+ else {
822
+ // Unreachable is not a verdict on the tokens: save and let the
823
+ // daemon report on its first connection attempt.
824
+ const detail = bot.status === 'unreachable' ? bot.detail : app.detail;
825
+ writeLine(streams.stdout, `! Could not reach Slack to verify (${detail}). Saved the tokens anyway — \`${serveCommand()}\` will report on startup.`);
826
+ }
827
+ };
828
+ /**
829
+ * Every agent the gateway would dispatch to: the built-in Stratus
830
+ * agent, the ~/.stratus/agents roster, and the configured default soul,
831
+ * which `stratus setup` can point at a file anywhere. The gateway
832
+ * registers all three, so a Channels list built from the agents
833
+ * directory alone would hide agents Slack can perfectly well talk to.
834
+ * Reads state.soulPath rather than the saved config so a soul chosen
835
+ * earlier in this same setup session is already connectable.
836
+ */
837
+ const channelRoster = async () => {
838
+ const warnOnce = (message) => writeLine(streams.stderr, `Warning: ${message}.`);
839
+ // Seeded before the roster loads, exactly as loadRoster does it: a
840
+ // fresh install with no soul files still has an agent to put on
841
+ // Slack, and a roster file that declares id "stratus" cannot take the
842
+ // built-in's place — the gateway skips it, so offering it here would
843
+ // name an app after an agent that never receives the messages.
844
+ const entries = [{ soul: { agent: { ...DEFAULT_STRATUS_AGENT } } }];
845
+ // loadRosterSouls refuses a roster whose files collide, and drops the
846
+ // ones claiming the reserved built-in id, so what comes back here is
847
+ // already unambiguous.
848
+ let rosterSouls = [];
849
+ let rosterLoaded = true;
850
+ try {
851
+ rosterSouls = await loadRosterSouls(env, warnOnce);
852
+ }
853
+ catch (error) {
854
+ // A roster that cannot say who its agents are cannot have channels
855
+ // configured for them — but the rest of setup (providers, models,
856
+ // sign-ins) still works, so this reports and moves on rather than
857
+ // taking the whole command down.
858
+ rosterLoaded = false;
859
+ warnOnce(error instanceof Error ? error.message : String(error));
860
+ // Nothing is offered, not merely the colliding pair. A roster that
861
+ // refuses to load fails `createGateway.start()` outright, so no
862
+ // agent is servable — not the built-in seeded above, and not the
863
+ // configured soul resolved below (which can itself BE one of the
864
+ // colliding files). Listing any of them invites connecting a Slack
865
+ // app to an agent the daemon cannot bring online, and connecting an
866
+ // app is the expensive half of that mistake.
867
+ return { entries: [], loaded: false };
868
+ }
869
+ entries.push(...rosterSouls);
870
+ // The soul a run resolves to, in resolveSoulPath's own order: an env
871
+ // override outranks the config value this setup session is editing.
872
+ // Listing the config soul while `stratus serve` registers the env one
873
+ // would store tokens against an id the adapter then skips.
874
+ const processEnv = readProcessEnv(env);
875
+ const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL);
876
+ if (typeof envSoul === 'string' && state.soulPath && envSoul !== state.soulPath) {
877
+ warnOnce(`STRATUS_SOUL points at ${envSoul}, which outranks the configured ${state.soulPath} — Channels lists what a run would actually use`);
878
+ }
879
+ const effectiveSoul = typeof envSoul === 'string' ? envSoul : state.soulPath;
880
+ if (!effectiveSoul) {
881
+ return { entries, loaded: rosterLoaded };
882
+ }
883
+ const resolved = path.resolve(readWorkingDirectory(env), effectiveSoul);
884
+ if (entries.some((entry) => entry.path === resolved)) {
885
+ return { entries, loaded: rosterLoaded };
886
+ }
887
+ try {
888
+ const soul = await loadSoulFile(resolved);
889
+ // An explicit id can collide with a roster file, or with the
890
+ // built-in. The gateway's defaultAgentId replaces the registered
891
+ // source whenever it is pathless (the built-in) or resolves to a
892
+ // different file, so the configured soul is the one Slack actually
893
+ // dispatches to — offering the namesake here would connect an app
894
+ // to a different agent than it names.
895
+ const collision = entries.findIndex((entry) => entry.soul.agent.id === soul.agent.id);
896
+ if (collision >= 0) {
897
+ entries.splice(collision, 1);
898
+ }
899
+ entries.unshift({ soul, path: resolved });
900
+ }
901
+ catch (error) {
902
+ warnOnce(`could not read the default soul ${resolved} (${error instanceof Error ? error.message : String(error)})`);
903
+ }
904
+ return { entries, loaded: rosterLoaded };
905
+ };
906
+ const serviceSummary = () => {
907
+ if (!servicePlatform(serviceEnvFor(env))) {
908
+ return `not available on ${env.processEnv?.OSTYPE ?? process.platform} — run \`stratus serve\` yourself`;
909
+ }
910
+ if (!state.service.install) {
911
+ return 'off — start stratusd yourself with `stratus serve`';
912
+ }
913
+ return state.service.runAtLogin
914
+ ? 'stratusd runs after setup, and at every login'
915
+ : 'stratusd runs after setup, but not at login';
916
+ };
917
+ /**
918
+ * Whether the roster keeps answering once this terminal closes. On by
919
+ * default: an agent you have to remember to start is not always-on, and
920
+ * every Slack app configured above is silent until stratusd runs.
921
+ */
922
+ const chooseService = async () => {
923
+ if (!servicePlatform(serviceEnvFor(env))) {
924
+ writeLine(streams.stdout);
925
+ writeLine(streams.stdout, `Stratus has no service integration for ${process.platform} yet — run \`stratus serve\` yourself, or supervise it however you prefer.`);
926
+ await prompter.ask('Press Enter to return to the menu… ');
927
+ return;
928
+ }
929
+ const choice = await prompter.select('Always on — keep stratusd running in the background', [
930
+ 'Run after setup, and start again at every login (recommended)',
931
+ 'Run after setup, but do not start at login',
932
+ 'Do not run it for me — I will start `stratus serve` myself',
933
+ 'Back',
934
+ ], {
935
+ footnote: process.platform === 'darwin'
936
+ // Said here rather than left to be discovered after a reboot.
937
+ ? 'A LaunchAgent starts at login, not at power-on. For a machine that should recover unattended, turn on automatic login too.'
938
+ : 'A user service starts at login. `loginctl enable-linger` keeps it up on a machine you do not stay logged in to.',
939
+ });
940
+ if (choice.kind !== 'index' || choice.index === 3) {
941
+ return;
942
+ }
943
+ state.service = {
944
+ install: choice.index !== 2,
945
+ runAtLogin: choice.index === 0,
946
+ };
947
+ };
948
+ /**
949
+ * What a plugin's block should say when setup writes it, beyond
950
+ * `enabled`. Only settings without which the plugin is installed,
951
+ * enabled, and still useless — the failure this menu exists to stop
952
+ * anyone reaching by accident:
953
+ *
954
+ * - `tool-fs` roots are the whole boundary. With none, the plugin loads
955
+ * and every call fails "No filesystem roots are configured", which
956
+ * reads to an agent as a broken tool rather than an unset one.
957
+ * - `plugin-mcp` requires `servers`, and its value is a set of endpoints
958
+ * only the operator knows. A block written without it is refused at
959
+ * load, so this menu offers no one-key enable for it.
960
+ *
961
+ * `tool-shell`, `tool-web` and `tool-browser` work from `{ enabled:
962
+ * true }` — their settings narrow what is already permitted, and the
963
+ * plugin without them is restrictive rather than broken.
964
+ */
965
+ const PLUGIN_SETUP = {
966
+ '@stratusagent/tool-fs': {
967
+ label: 'Files',
968
+ grants: 'fs.read, fs.list, fs.search, fs.write',
969
+ needs: {
970
+ key: 'roots',
971
+ question: 'Which directories may agents read and write? (comma-separated, e.g. ~/notes): ',
972
+ placeholder: '~/notes',
973
+ list: true,
974
+ },
975
+ },
976
+ '@stratusagent/tool-shell': { label: 'Shell', grants: 'shell.run' },
977
+ '@stratusagent/tool-web': { label: 'Web', grants: 'web.fetch' },
978
+ '@stratusagent/tool-browser': {
979
+ label: 'Browser',
980
+ grants: 'browser.goto, browser.read, browser.screenshot, browser.act',
981
+ // The package depends on `playwright-core`, which downloads no
982
+ // browser on purpose — a few megabytes rather than 150. So the
983
+ // browser is one you already have or one you fetch, and without
984
+ // either every call fails: enabled and unusable, the state this menu
985
+ // exists to prevent. Setup cannot check for it without importing the
986
+ // plugin, so it says so instead.
987
+ note: 'It drives a browser you already have. If none is found, run `npx playwright install chromium`, '
988
+ + 'or point it at yours with `channel` or `executablePath` under plugins["@stratusagent/tool-browser"].',
989
+ },
990
+ '@stratusagent/plugin-mcp': {
991
+ label: 'MCP bridge',
992
+ grants: 'mcp.<server>.<tool>, discovered at connect',
993
+ // Setup checks that `servers` is an object and stops there. What
994
+ // each entry needs — exactly one of `command` or `url`, and the
995
+ // rest of `resolveServerSpec` — is the plugin's rule, and both ways
996
+ // to apply it here are worse than not applying it: a copy in this
997
+ // file drifts from the plugin the first time it gains a transport,
998
+ // and importing the package to ask means calling `createPlugin`,
999
+ // which the loader's own comment says may open a socket. So the
1000
+ // menu says what it did not check instead of implying it did. The
1001
+ // claim that needs qualifying is the grant line's "the next time
1002
+ // the daemon starts", which is a prediction about a block setup
1003
+ // never wrote.
1004
+ note: 'Setup checked that servers is a block and no further — reading what is inside it means loading the plugin, '
1005
+ + 'which setup never does. If the bridge refuses an entry, the daemon says "plugin @stratusagent/plugin-mcp did not load" '
1006
+ + 'at startup, and `stratus serve` shows that directly.',
1007
+ byHand: {
1008
+ key: 'servers',
1009
+ reason: 'it needs a servers block naming each MCP server — see docs/reference/config.md',
1010
+ },
1011
+ },
1012
+ };
1013
+ /** Every package this menu lists: the first-party set, plus whatever is already configured. */
1014
+ const pluginPackages = () => [
1015
+ ...FIRST_PARTY_CAPABILITY_PACKAGES,
1016
+ ...Object.keys(state.plugins ?? {}).filter((name) => !FIRST_PARTY_CAPABILITY_PACKAGES.includes(name)),
1017
+ ];
1018
+ const pluginEnabled = (name) => {
1019
+ const block = state.plugins?.[name];
1020
+ return block !== undefined && block.enabled !== false;
1021
+ };
1022
+ /**
1023
+ * What the daemon would refuse about a block, or `undefined` if it would
1024
+ * take it — the loader's own preflight, not a third opinion.
1025
+ *
1026
+ * `stratus plugins` already runs `preflightPlugin` to answer exactly this
1027
+ * about an enabled plugin, and the menu asking it a different way was how
1028
+ * three rounds of review found a different hand-rolled check short of the
1029
+ * real rule: a `servers` that is a string, `roots` that are numbers, a
1030
+ * `timeoutMs` that is not an integer. The manifest's schema knows all
1031
+ * three, so the fix is to ask it rather than to keep guessing at it.
1032
+ *
1033
+ * A package that will not resolve has no manifest to ask, and that is
1034
+ * `undefined` too: setup cannot preflight what is not installed, and
1035
+ * saying nothing is wrong is the same answer it has always given there.
1036
+ */
1037
+ const pluginConfigProblem = async (name, block) => {
1038
+ try {
1039
+ const { manifest, directory } = await readPluginManifest(name, {
1040
+ resolve: (target) => import.meta.resolve(target),
1041
+ });
1042
+ await preflightPlugin(manifest, directory, block, workspacesDirPath(env));
1043
+ return undefined;
1044
+ }
1045
+ catch (error) {
1046
+ // A package that will not *resolve* is not a verdict about the block —
1047
+ // `host.resolve` throws a plain Error for that, and setup has always
1048
+ // answered "nothing known" there. Everything the loader itself
1049
+ // refuses is: a schema mismatch and a bad `toolRisks` override raise
1050
+ // `PluginConfigError`, while an unparseable manifest and a skill file
1051
+ // that is missing, unreadable or outside the package raise
1052
+ // `PluginManifestError`. Catching only the first read the second as
1053
+ // "no problem" and enabled a plugin the daemon rejects before it
1054
+ // registers anything.
1055
+ if (error instanceof PluginConfigError || error instanceof PluginManifestError) {
1056
+ return error.message;
1057
+ }
1058
+ return undefined;
1059
+ }
1060
+ };
1061
+ /**
1062
+ * What setup did not check about this plugin, or `undefined` if there is
1063
+ * nothing left unchecked.
1064
+ *
1065
+ * One concept, because the two cases print the same kind of sentence and
1066
+ * the verdict below has to be qualified for both. `preflightPlugin` never
1067
+ * imports the package — that is what lets it answer without starting
1068
+ * anything — so a plugin can pass every check here and still be refused
1069
+ * by `loadPlugins` for not exporting `createPlugin(config)`, returning
1070
+ * something that is not a plugin, or failing `setup()`. For the packages
1071
+ * this file knows, that is not a real risk and only the declared caveat
1072
+ * applies; for one it does not know, it is exactly the risk, and reading
1073
+ * an absent `PLUGIN_SETUP` entry as "nothing to declare" claimed a
1074
+ * readiness setup had no way to establish.
1075
+ */
1076
+ const uncheckedReason = (name) => {
1077
+ const setup = PLUGIN_SETUP[name];
1078
+ if (setup === undefined) {
1079
+ return `Setup read ${name}'s manifest and no more — it never loads a package, so whether it exports createPlugin(config) `
1080
+ + 'is something only a daemon start will tell you. If it does not, the daemon says "plugin '
1081
+ + `${name} did not load" and registers nothing.`;
1082
+ }
1083
+ return setup.note;
1084
+ };
1085
+ /**
1086
+ * The tool names a plugin contributes, read from its manifest.
1087
+ *
1088
+ * The manifest rather than `PLUGIN_SETUP.grants`, which is display text
1089
+ * and exists only for the packages this file knows: a third-party plugin
1090
+ * has real names too, and they are what decides whether a soul's
1091
+ * `tools:` list already reaches it. `undefined` where no manifest can be
1092
+ * read — a question setup declines rather than answers wrongly.
1093
+ */
1094
+ const pluginContributions = async (name) => {
1095
+ try {
1096
+ const { manifest } = await readPluginManifest(name, {
1097
+ resolve: (target) => import.meta.resolve(target),
1098
+ });
1099
+ return {
1100
+ // The manifest's own name, which is *not* the config key: the same
1101
+ // package reached through an alias or a subpath specifier is a
1102
+ // different key and the same `packageName`, and the loader
1103
+ // qualifies skills with this one. Carried out of here so callers
1104
+ // cannot reach for the specifier by accident.
1105
+ packageName: manifest.packageName,
1106
+ tools: manifest.contributes.tools.map((tool) => tool.name),
1107
+ namespaces: manifest.contributes.toolsDiscovered.map((entry) => entry.namespace),
1108
+ // The qualified form the loader stages them under, which is what a
1109
+ // `skills:` entry has to match.
1110
+ skills: manifest.contributes.skills.map((skill) => `${manifest.packageName}:${skill.id}`),
1111
+ };
1112
+ }
1113
+ catch {
1114
+ return undefined;
1115
+ }
1116
+ };
1117
+ /**
1118
+ * Whether the setting a plugin is useless without resolves to something
1119
+ * for one agent, given the block that would be written.
1120
+ *
1121
+ * `resolvePluginAgentConfig` shallow-merges, so a per-agent value
1122
+ * *replaces* the fleet-wide one rather than extending it. That is why
1123
+ * `agents.<id>.roots: []` is how a config takes one agent out, and why a
1124
+ * fleet-wide list says nothing about an agent that overrides it.
1125
+ */
1126
+ const pluginSettingReaches = (block, key, agentId) => {
1127
+ const agents = block?.agents;
1128
+ const own = typeof agents === 'object' && agents !== null && !Array.isArray(agents)
1129
+ ? agents[agentId]
1130
+ : undefined;
1131
+ const value = typeof own === 'object' && own !== null && !Array.isArray(own)
1132
+ && key in own
1133
+ ? own[key]
1134
+ : block?.[key];
1135
+ // Nonempty is this menu's own question — "would an agent get anything"
1136
+ // — and the only part of it the manifest does not answer, since a
1137
+ // schema that permits `roots: []` is right to. Whether the entries are
1138
+ // the right *type* is the manifest's, checked by `pluginConfigProblem`
1139
+ // before enabling, so a second test for it here would be the copy this
1140
+ // file keeps growing.
1141
+ return Array.isArray(value) && value.length > 0;
1142
+ };
1143
+ const pluginsSummary = () => {
1144
+ const enabled = pluginPackages().filter((name) => pluginEnabled(name));
1145
+ if (enabled.length === 0) {
1146
+ return 'none — agents have no tools beyond the built-ins';
1147
+ }
1148
+ return enabled.map((name) => name.replace('@stratusagent/', '')).join(', ');
1149
+ };
1150
+ /**
1151
+ * Enabling is only the second of two gates, and this menu owns just that
1152
+ * one. The soul's `tools:` list is the other, and setup does not edit
1153
+ * souls — so the line to paste is printed rather than applied, which is
1154
+ * also the honest thing: which agent gets a tool is not a decision this
1155
+ * menu has the standing to make.
1156
+ */
1157
+ const printSoulGrantLine = async (name) => {
1158
+ const grants = PLUGIN_SETUP[name]?.grants;
1159
+ const first = grants?.split(',')[0]?.trim();
1160
+ writeLine(streams.stdout);
1161
+ // A soul with no `tools:` key is allowlisted for *every* registered
1162
+ // tool — `matchesToolAllowlist` treats an omitted list as permissive,
1163
+ // and the built-in `stratus` agent has none, so a fresh install always
1164
+ // has one. Enabling a plugin therefore can grant capability
1165
+ // immediately, and saying "no agent can call it yet" would understate
1166
+ // what just happened in the most common configuration there is. Read
1167
+ // the roster and say which of the two it was.
1168
+ const { entries, loaded } = await channelRoster();
1169
+ if (!loaded) {
1170
+ // The roster refused to load, so `entries` is empty — which is not
1171
+ // evidence that every soul has a `tools:` list. Claiming nothing can
1172
+ // call the plugin would be a statement about a file this command
1173
+ // could not read, and false the moment the collision is fixed if any
1174
+ // soul omits its allowlist. Same posture as the Channels menu.
1175
+ writeLine(streams.stdout, `${name} is enabled. Who can call it is unknown until the roster loads — fix the error above, then run \`stratus plugins\`.`);
1176
+ return;
1177
+ }
1178
+ const contributed = await pluginContributions(name);
1179
+ const label = (list) => list.map((entry) => `${entry.soul.agent.name} (${entry.soul.agent.id})`).join(', ');
1180
+ // Every contributed tool name, concrete and discovered alike. A
1181
+ // discovered namespace is matched by overlap rather than prefix, which
1182
+ // `soulGrantsTool` handles; both are things a `tools:` entry can reach.
1183
+ const everyTool = contributed === undefined ? [] : [...contributed.tools, ...contributed.namespaces];
1184
+ // What one soul's allowlist actually reaches — the *names*, not a
1185
+ // yes/no. `tools: [fs.read]` grants one of the four tool-fs
1186
+ // contributes, and reporting the plugin as callable said all four were.
1187
+ //
1188
+ // Reported as the soul's own **entries**, never as the plugin's
1189
+ // declarations, because `toolScopesOverlap` is overlap and not
1190
+ // containment: a soul granting `mcp.linear.get_issue` overlaps a
1191
+ // declared `mcp.*` — which is the right test for "does this soul reach
1192
+ // the plugin" and the wrong thing to print, since it read back as the
1193
+ // soul granting the whole namespace. An entry says exactly what it
1194
+ // says, and a discovered namespace has no tool names to enumerate
1195
+ // until the bridge connects.
1196
+ const reached = (entry) => {
1197
+ const tools = entry.soul.agent.tools;
1198
+ if (tools === undefined) {
1199
+ return everyTool;
1200
+ }
1201
+ if (contributed === undefined) {
1202
+ // No manifest, no names to test — no claim either way about a soul
1203
+ // that named something specific.
1204
+ return [];
1205
+ }
1206
+ return tools.filter((granted) => contributed.tools.some((tool) => soulGrantsTool([granted], tool, false))
1207
+ || contributed.namespaces.some((namespace) => soulGrantsTool([granted], namespace, true)));
1208
+ };
1209
+ /** Concrete contributed tools no entry of this soul's reaches. */
1210
+ const missedTools = (entry) => {
1211
+ const tools = entry.soul.agent.tools;
1212
+ if (tools === undefined || contributed === undefined) {
1213
+ return [];
1214
+ }
1215
+ // Only the concrete ones: a declared namespace registers its tools at
1216
+ // connect time, so setup cannot say which of them an entry misses.
1217
+ return contributed.tools.filter((tool) => !soulGrantsTool(tools, tool, false));
1218
+ };
1219
+ // Allowlisted is not the same as able to call it. `tool-fs` enabled
1220
+ // from a per-agent `roots` block leaves every *other* allowlisted soul
1221
+ // holding tools that throw "No filesystem roots are configured" on the
1222
+ // first call. Naming it as an agent that can call them would report the
1223
+ // installed-enabled-and-useless state this menu exists to prevent as
1224
+ // the success case.
1225
+ const needsKey = PLUGIN_SETUP[name]?.needs?.key;
1226
+ const settingReaches = (entry) => needsKey === undefined
1227
+ || pluginSettingReaches(state.plugins?.[name], needsKey, entry.soul.agent.id);
1228
+ const allowlisted = entries.filter((entry) => reached(entry).length > 0);
1229
+ const callable = allowlisted.filter(settingReaches);
1230
+ const unset = allowlisted.filter((entry) => !settingReaches(entry));
1231
+ const permissive = callable.filter((entry) => entry.soul.agent.tools === undefined);
1232
+ const explicit = callable.filter((entry) => entry.soul.agent.tools !== undefined);
1233
+ // The key travels with the agents rather than beside them, so the
1234
+ // branches below cannot ask about one and read the other.
1235
+ const shortfall = needsKey !== undefined && unset.length > 0
1236
+ ? { key: needsKey, agents: unset, one: unset.length === 1 }
1237
+ : undefined;
1238
+ // Skills are the other half of what a plugin contributes and they are
1239
+ // gated the other way round: an omitted `skills:` list is **none**,
1240
+ // deliberately, because a skill silently changing how an agent behaves
1241
+ // is worse than one it has to be told about. So no soul gains them by
1242
+ // default, and a line about tools cannot speak for them.
1243
+ const skillLine = () => {
1244
+ if (contributed === undefined || contributed.skills.length === 0) {
1245
+ return;
1246
+ }
1247
+ const one = contributed.skills.length === 1;
1248
+ writeLine(streams.stdout, `It also contributes ${one ? 'a skill' : 'skills'}: ${contributed.skills.join(', ')}.`);
1249
+ // Every id, not the first: `matchesSkillAllowlist` selects an exact id
1250
+ // or a package wildcard, so a one-item list from a plural sentence
1251
+ // grants one skill and silently leaves the rest off.
1252
+ //
1253
+ // The wildcard is built from the *manifest's* package name, never the
1254
+ // config key this menu was called with. They are the same for every
1255
+ // ordinary install and differ for an alias or a subpath specifier,
1256
+ // and the loader qualifies skills with the manifest's — so a wildcard
1257
+ // spelled from the key would grant nothing while the exact ids beside
1258
+ // it worked, which is the worst way for two halves of one sentence to
1259
+ // disagree.
1260
+ writeLine(streams.stdout, `Those are not granted by \`tools:\` and no soul gets them by default — an omitted \`skills:\` list is none. Add \`skills: [${contributed.skills.join(', ')}]\` to grant ${one ? 'it' : 'them'}${one ? '' : `, or \`skills: [${contributed.packageName}:*]\` for every skill this package contributes`}.`);
1261
+ };
1262
+ if (callable.length > 0) {
1263
+ if (permissive.length > 0) {
1264
+ writeLine(streams.stdout, `${name} is enabled — and ${label(permissive)} ${permissive.length === 1 ? 'has' : 'have'} no \`tools:\` list, which means every registered tool.`);
1265
+ writeLine(streams.stdout, uncheckedReason(name) !== undefined
1266
+ ? `So ${grants ?? 'what it contributes'} ${permissive.length === 1 ? 'is' : 'are'} what ${permissive.length === 1 ? 'that agent' : 'those agents'} would gain at the next daemon start — subject to the caveat above, which setup did not check.`
1267
+ : `So ${grants ?? 'what it contributes'} ${permissive.length === 1 ? 'is' : 'are'} callable by ${permissive.length === 1 ? 'that agent' : 'those agents'} the next time the daemon starts.`);
1268
+ writeLine(streams.stdout, 'Give a soul a `tools:` list to narrow that. `stratus plugins` shows who can call what.');
1269
+ }
1270
+ for (const entry of explicit) {
1271
+ // Per soul, and naming what its list *matched* — an allowlist can
1272
+ // grant one of four tools, and saying the plugin is callable there
1273
+ // reported the other three as available when the runtime denies
1274
+ // them.
1275
+ const names = reached(entry);
1276
+ const rest = missedTools(entry);
1277
+ writeLine(streams.stdout, `${name} is enabled — and ${entry.soul.agent.name} (${entry.soul.agent.id}) already grants ${names.join(', ')} in \`tools:\`${rest.length > 0 ? `, but not ${rest.join(', ')}` : ''}.`);
1278
+ }
1279
+ if (shortfall !== undefined) {
1280
+ writeLine(streams.stdout, `${label(shortfall.agents)} ${shortfall.one ? 'is' : 'are'} allowlisted too, but ${shortfall.key} is not set for ${shortfall.one ? 'it' : 'them'} — every call would fail until it is, under plugins["${name}"].agents.`);
1281
+ }
1282
+ skillLine();
1283
+ return;
1284
+ }
1285
+ if (shortfall !== undefined) {
1286
+ // Allowlisted souls, every one of them short the setting: neither the
1287
+ // "callable by" line above nor the "no soul names it" one below is
1288
+ // true, and each would send the operator to fix the wrong gate.
1289
+ writeLine(streams.stdout, `${name} is enabled. No agent can call it yet — ${label(shortfall.agents)} ${shortfall.one ? 'is' : 'are'} allowlisted for it, but ${shortfall.key} is not set for ${shortfall.one ? 'it' : 'them'} and every call would fail.`);
1290
+ writeLine(streams.stdout, `Set ${shortfall.key} for ${shortfall.one ? 'it' : 'them'} under plugins["${name}"].agents, then run \`stratus plugins\` to see the whole chain.`);
1291
+ skillLine();
1292
+ return;
1293
+ }
1294
+ if (everyTool.length === 0) {
1295
+ // A plugin that contributes no tools at all — skills only, which is a
1296
+ // valid manifest. Sending the operator to edit `tools:` would name
1297
+ // the wrong key entirely.
1298
+ if (contributed !== undefined && contributed.skills.length > 0) {
1299
+ writeLine(streams.stdout, `${name} is enabled. It contributes no tools, so there is nothing for a \`tools:\` list to name.`);
1300
+ skillLine();
1301
+ return;
1302
+ }
1303
+ writeLine(streams.stdout, `${name} is enabled. What it contributes is in its manifest — run \`stratus plugins\` to see it and who can reach it.`);
1304
+ return;
1305
+ }
1306
+ writeLine(streams.stdout, `${name} is enabled. No agent can call it yet — every soul in the roster has a \`tools:\` list and none of them names what it contributes, and a soul grants tools by naming them:`);
1307
+ writeLine(streams.stdout, ` tools: [${everyTool[0]}]`);
1308
+ writeLine(streams.stdout, `Add it to the \`tools:\` list in ${state.soulPath ?? 'your agent\'s soul file'}, then run \`stratus plugins\` to see the whole chain and what it contributes.`);
1309
+ skillLine();
1310
+ };
1311
+ const choosePlugins = async () => {
1312
+ while (true) {
1313
+ const packages = pluginPackages();
1314
+ // Every package plus Back — the count the widest prefix comes from.
1315
+ const reserved = menuPrefixWidth(packages.length + 1);
1316
+ const options = packages.map((name) => {
1317
+ const short = name.replace('@stratusagent/', '');
1318
+ const installed = packageInstalled(name, env);
1319
+ const status = !installed
1320
+ ? '— not installed'
1321
+ : pluginEnabled(name)
1322
+ ? '✓ enabled'
1323
+ : 'installed, not enabled';
1324
+ return fitMenuRow(short.padEnd(18) + status, reserved);
1325
+ });
1326
+ options.push('Back');
1327
+ const choice = await prompter.select('Plugins — what your agents can do (installing one grants nothing on its own)', options, { footnote: `More plugins — ${PLUGIN_MARKETPLACE_URL}` });
1328
+ if (choice.kind !== 'index' || choice.index === options.length - 1) {
1329
+ return;
1330
+ }
1331
+ const name = packages[choice.index];
1332
+ if (!name) {
1333
+ return;
1334
+ }
1335
+ await choosePlugin(name);
1336
+ }
1337
+ };
1338
+ const choosePlugin = async (name) => {
1339
+ const setup = PLUGIN_SETUP[name];
1340
+ const installed = packageInstalled(name, env);
1341
+ const enabled = pluginEnabled(name);
1342
+ // Only a block still *missing* that setting is refused. A configured
1343
+ // one switched off — by this menu, which promises its settings are kept
1344
+ // for turning it back on — must be able to come back, or the promise is
1345
+ // false and the switch is one-way.
1346
+ const byHandValue = setup?.byHand !== undefined ? state.plugins?.[name]?.[setup.byHand.key] : undefined;
1347
+ // Present *and* something the loader would take. Presence alone read
1348
+ // `servers: "invalid"` as a config to switch back on; asking the
1349
+ // manifest instead of inventing a shape test here is the same move the
1350
+ // enable path makes, and there is one rule between them.
1351
+ const byHandProblem = setup?.byHand !== undefined && byHandValue !== undefined
1352
+ ? await pluginConfigProblem(name, state.plugins?.[name] ?? {})
1353
+ : undefined;
1354
+ const configuredByHand = byHandValue !== undefined && byHandProblem === undefined;
1355
+ if (setup?.byHand && !enabled && !configuredByHand) {
1356
+ writeLine(streams.stdout);
1357
+ writeLine(streams.stdout, `${name} contributes ${setup.grants}.`);
1358
+ writeLine(streams.stdout, `Setup does not enable it: ${setup.byHand.reason}.`);
1359
+ if (byHandProblem !== undefined) {
1360
+ // Present, and not what the loader will take. Without this the
1361
+ // refusal reads as "you have not set it", which sends an operator
1362
+ // who plainly has to look for a menu bug rather than at the value.
1363
+ writeLine(streams.stdout, `A daemon would refuse the block you have: ${byHandProblem}`);
1364
+ }
1365
+ if (!installed) {
1366
+ writeLine(streams.stdout, `Install it with: npm install -g ${name}`);
1367
+ }
1368
+ await prompter.ask('Press Enter to return to the menu… ');
1369
+ return;
1370
+ }
1371
+ if (!installed) {
1372
+ // Enabled with the package gone — an uninstall, or a config copied
1373
+ // from another machine. The block is exactly what an operator would
1374
+ // want to clear, and reaching it used to require installing the
1375
+ // package first in order to switch it off.
1376
+ //
1377
+ // A key that could never end up loaded is offered no install: the row
1378
+ // comes from `Object.keys(state.plugins)`, so a typo, a stray
1379
+ // character or a copied `pkg@1.2.3` arrives here as a package name.
1380
+ //
1381
+ // The bare-name rule, not the installable-specifier one: this key is
1382
+ // what the loader hands `import.meta.resolve`, and Node does not
1383
+ // resolve a version suffix — so `npm install -g @scope/foo@latest`
1384
+ // succeeds and the plugin stays "not installed" forever. Switching it
1385
+ // off stays available either way; a block keyed by something that can
1386
+ // never load is exactly one to clear.
1387
+ const installable = isPackageName(name);
1388
+ const actions = enabled
1389
+ ? [...(installable ? ['Install it with npm install -g'] : []), 'Switch it off in the config', 'Back']
1390
+ : [...(installable ? ['Install it with npm install -g, then enable it'] : []), 'Back'];
1391
+ if (!installable) {
1392
+ writeLine(streams.stdout);
1393
+ writeLine(streams.stdout, isInstallableSpecifier(name)
1394
+ ? `${name} carries a version, and a plugins key is also the module specifier a daemon imports — Node does not resolve one, so installing it would leave the plugin absent. Key the block by the package name alone.`
1395
+ : `${name} is not a package name npm can install — check the key in your plugins config.`);
1396
+ }
1397
+ const answer = await prompter.select(enabled
1398
+ ? `${name} is enabled in your config but not installed, so a daemon registers nothing for it.`
1399
+ : `${name} is not installed. It contributes ${setup?.grants ?? 'its own tools'}.`, actions);
1400
+ if (answer.kind !== 'index' || answer.index === actions.length - 1) {
1401
+ return;
1402
+ }
1403
+ if (enabled && answer.index === actions.length - 2) {
1404
+ disablePlugin(name);
1405
+ return;
1406
+ }
1407
+ writeLine(streams.stdout, `Running: npm install -g ${name}`);
1408
+ const result = await (env.packageInstaller ?? defaultPackageInstaller)([name])
1409
+ .catch((error) => ({
1410
+ ok: false,
1411
+ message: error instanceof Error ? error.message : String(error),
1412
+ }));
1413
+ if (!result.ok) {
1414
+ writeLine(streams.stderr, `Could not install: ${result.message}`);
1415
+ writeLine(streams.stderr, `Run \`npm install -g ${name}\` yourself, then re-run setup to enable it.`);
1416
+ return;
1417
+ }
1418
+ writeLine(streams.stdout, `Installed ${name}.`);
1419
+ // Enabling proceeds on npm's exit code, not a second resolve: a
1420
+ // package written into the global prefix a moment ago need not be
1421
+ // resolvable from THIS process, whose module resolution was fixed
1422
+ // when it started. The same reasoning the optional-package step
1423
+ // already uses for the dashboard.
1424
+ await enablePlugin(name);
1425
+ return;
1426
+ }
1427
+ if (!enabled) {
1428
+ const answer = await prompter.select(`${name} is installed but not enabled. It contributes ${setup?.grants ?? 'its own tools'}.`, ['Enable it', 'Back']);
1429
+ if (answer.kind !== 'index' || answer.index === 1) {
1430
+ return;
1431
+ }
1432
+ await enablePlugin(name);
1433
+ return;
1434
+ }
1435
+ const current = state.plugins?.[name] ?? {};
1436
+ const actions = setup?.needs
1437
+ ? [`Change ${setup.needs.key}`, 'Disable it', 'Back']
1438
+ : ['Disable it', 'Back'];
1439
+ const answer = await prompter.select(`${name} is enabled.${setup?.needs && Array.isArray(current[setup.needs.key])
1440
+ ? ` ${setup.needs.key}: ${current[setup.needs.key].join(', ')}`
1441
+ : ''}`, actions);
1442
+ if (answer.kind !== 'index' || answer.index === actions.length - 1) {
1443
+ return;
1444
+ }
1445
+ if (setup?.needs && answer.index === 0) {
1446
+ await enablePlugin(name);
1447
+ return;
1448
+ }
1449
+ disablePlugin(name);
1450
+ };
1451
+ /**
1452
+ * Switched off, never deleted. The loader treats `enabled: false` and an
1453
+ * absent key the same, but the operator does not: a block carries
1454
+ * `agents` overrides and `toolRisks` that setup never asked about, and
1455
+ * dropping them would be #161 again — a menu deleting config it does not
1456
+ * own, one plugin at a time instead of all four blocks at once.
1457
+ */
1458
+ const disablePlugin = (name) => {
1459
+ state.plugins = {
1460
+ ...(state.plugins ?? {}),
1461
+ [name]: { ...(state.plugins?.[name] ?? {}), enabled: false },
1462
+ };
1463
+ writeLine(streams.stdout, packageInstalled(name, env)
1464
+ ? `${name} is switched off. The package is still installed, and its settings are kept for when you turn it back on.`
1465
+ : `${name} is switched off, so a daemon stops trying to load it. Its settings are kept for when you install it again.`);
1466
+ };
1467
+ const enablePlugin = async (name) => {
1468
+ const setup = PLUGIN_SETUP[name];
1469
+ const existing = state.plugins?.[name] ?? {};
1470
+ const block = { ...existing, enabled: true };
1471
+ // Whether the prompt below kept a per-agent value rather than writing a
1472
+ // fleet-wide one. Said after the block is checked, not before: the
1473
+ // alternative printed "keeping your per-agent roots" and then refused
1474
+ // to enable, which is two answers to one question.
1475
+ let keptPerAgent = false;
1476
+ if (setup?.needs) {
1477
+ const needsKey = setup.needs.key;
1478
+ const prior = existing[needsKey];
1479
+ const prefill = Array.isArray(prior) ? prior.join(', ') : undefined;
1480
+ const answer = (await prompter.ask(setup.needs.question, ...(prefill !== undefined ? [{ prefill }] : []))).trim();
1481
+ const values = answer.split(',').map((value) => value.trim()).filter((value) => value.length > 0);
1482
+ if (values.length === 0) {
1483
+ // A per-agent block satisfies this on its own: the plugin resolves
1484
+ // its settings per session, so `agents.<id>.roots` with nothing at
1485
+ // the top level is a working config — and a narrower one than any
1486
+ // fleet-wide answer this prompt could take. Demanding a global
1487
+ // value here would push an operator to widen access to get past a
1488
+ // menu.
1489
+ //
1490
+ // Deleted, not merely left unwritten: `block` is a copy of the
1491
+ // existing one, so keeping the key would leave every unoverridden
1492
+ // agent on the old fleet-wide roots while the line below says
1493
+ // nothing is granted fleet-wide. Saying access narrowed while it
1494
+ // did not is the worst way for this menu to be wrong.
1495
+ //
1496
+ // Only an interactive run reaches this with a key to delete: the
1497
+ // prefill is editable on a TTY, while the piped prompter returns it
1498
+ // for an empty line. So there is no test — `setupInput` forces the
1499
+ // non-interactive path, where an empty answer means "keep" and this
1500
+ // deletes a key that was never there.
1501
+ delete block[needsKey];
1502
+ // Asked of the block as it now stands, and only of agents the
1503
+ // roster actually serves. An override left behind by a deleted
1504
+ // agent grants nobody anything: every served agent would resolve
1505
+ // an empty list and every call would fail, while this menu
1506
+ // reported the plugin enabled — the same "configured and useless"
1507
+ // state the prompt exists to prevent, reached by the branch that
1508
+ // was supposed to allow the narrow config. An unreadable roster
1509
+ // cannot answer, so it does not qualify anything either.
1510
+ const { entries, loaded } = await channelRoster();
1511
+ const coveredPerAgent = loaded
1512
+ && entries.some((entry) => pluginSettingReaches(block, needsKey, entry.soul.agent.id));
1513
+ if (!coveredPerAgent) {
1514
+ // Otherwise not written at all rather than written empty: an
1515
+ // enabled block with no roots anywhere is the exact "installed,
1516
+ // enabled, and useless" state this menu exists to keep anyone
1517
+ // from reaching by accident.
1518
+ //
1519
+ // "Left as it was" is the whole outcome, and what that means
1520
+ // differs by where the operator came from. Reaching this by
1521
+ // erasing an existing value — Change roots, prefill deleted —
1522
+ // leaves the old value in the saved config, because the return
1523
+ // is before `state.plugins` is written. Saying "it grants
1524
+ // nothing without roots" there describes a block that still has
1525
+ // roots, and an operator who meant to take access away would
1526
+ // read it as confirmation. Setup names what it kept instead, and
1527
+ // how to actually drop it, rather than dropping it on a guess:
1528
+ // an empty answer is not an unambiguous "revoke", and this menu
1529
+ // does not widen or narrow a boundary the operator did not.
1530
+ const kept = existing[needsKey];
1531
+ writeLine(streams.stdout, Array.isArray(kept) && kept.length > 0
1532
+ ? `Nothing entered, so ${name} kept the ${needsKey} it already had (${kept.join(', ')}) — setup did not remove them. Edit plugins["${name}"].${needsKey} to change what is reachable.`
1533
+ : `Nothing entered, so ${name} was left as it was — it grants nothing without ${needsKey}.`);
1534
+ return;
1535
+ }
1536
+ keptPerAgent = true;
1537
+ }
1538
+ else {
1539
+ block[needsKey] = values;
1540
+ }
1541
+ }
1542
+ // The block that would be *written*, not the one that was there. Asking
1543
+ // before the prompt meant `Change roots` — the action offered precisely
1544
+ // to repair a bad value — refused on the value it exists to replace,
1545
+ // and an operator had no way through this menu to fix a setting this
1546
+ // menu owns. Settings it does not own are still caught here: the
1547
+ // replacement is folded in first, and whatever remains wrong is wrong
1548
+ // in the block a daemon would be handed.
1549
+ const problem = await pluginConfigProblem(name, block);
1550
+ if (problem !== undefined) {
1551
+ writeLine(streams.stdout);
1552
+ // "was not enabled" is only true where the block was not already
1553
+ // enabled. Reached from the install action on a config that already
1554
+ // says `enabled: true`, this returns before touching `state`, so the
1555
+ // old block survives and Save writes it — a daemon then tries the
1556
+ // package it just installed and refuses it, while setup had said it
1557
+ // was not enabled. Report what is actually there, and name the way
1558
+ // out; switching it off is the operator's call, not this branch's,
1559
+ // for the same reason the roots prompt does not revoke on a blank
1560
+ // line.
1561
+ writeLine(streams.stdout, existing.enabled === false || existing.enabled === undefined
1562
+ ? `${name} was not enabled — a daemon would refuse these settings: ${problem}`
1563
+ : `${name} is still enabled in your config, and a daemon would refuse these settings: ${problem}`);
1564
+ writeLine(streams.stdout, existing.enabled === false || existing.enabled === undefined
1565
+ ? 'Fix that in your config, then enable it here. `stratus plugins` reports the same check.'
1566
+ : 'Fix that in your config, or switch the plugin off here so a daemon stops trying to load it. `stratus plugins` reports the same check.');
1567
+ return;
1568
+ }
1569
+ if (keptPerAgent && setup?.needs) {
1570
+ writeLine(streams.stdout, `Keeping the per-agent ${setup.needs.key} already configured for ${name}; nothing is granted fleet-wide.`);
1571
+ }
1572
+ state.plugins = { ...(state.plugins ?? {}), [name]: block };
1573
+ const unchecked = uncheckedReason(name);
1574
+ if (unchecked !== undefined) {
1575
+ writeLine(streams.stdout, unchecked);
1576
+ }
1577
+ await printSoulGrantLine(name);
1578
+ };
1579
+ /**
1580
+ * Who may answer a gated call while nobody is watching. The two halves
1581
+ * are one decision: `remote` with nobody to ask behaves exactly like
1582
+ * `headless` — the call parks and the timeout denies it — so the mode
1583
+ * and the approvers are set on the same screen rather than in two places
1584
+ * that can disagree.
1585
+ */
1586
+ /**
1587
+ * The daemon's own verdict, not a second version of it. Every round of
1588
+ * review on this row found the hand-written summary short a clause the
1589
+ * engine applies — headless still runs already-authorized calls, an
1590
+ * explicit `timeoutMs: 0` parks instead of denying, the control API can
1591
+ * answer for an agent Slack cannot reach. The row now takes the first
1592
+ * clause `stratus plugins` prints and the submenu carries the rest, so
1593
+ * the two can differ in length and never in fact.
1594
+ */
1595
+ const approvalsSummary = async () => {
1596
+ const mode = state.approvals?.mode ?? 'headless';
1597
+ const { entries, loaded } = await channelRoster();
1598
+ // `headless` is a statement about the policy, not the roster, so it
1599
+ // survives a roster that will not load. Everything else here depends on
1600
+ // who is served: passing `undefined` would make `classifyApprovalReach`
1601
+ // skip the intersection and read every stored token as askable, so the
1602
+ // row would promise Slack asks for a fleet whose gateway refuses to
1603
+ // start. Same rule as the grant line and the Channels menu — an
1604
+ // unreadable roster is not evidence in either direction.
1605
+ if (mode === 'remote' && !loaded) {
1606
+ return ['remote — who can be asked is unknown until the roster loads; fix the error above, then `stratus plugins`'];
1607
+ }
1608
+ return unattendedReachParts(mode, state.approvals ?? {}, state.channels, entries.map((entry) => entry.soul.agent.id), apiApprovalsReachable(), env);
1609
+ };
1610
+ /** Whether a parked call could be settled through `POST /api/v1/approvals`. */
1611
+ const apiApprovalsReachable = () => packageInstalled('@stratusagent/control-api', env) && state.api?.enabled !== false;
1612
+ const approvalReach = async () => {
1613
+ const { entries, loaded } = await channelRoster();
1614
+ return classifyApprovalReach(state.approvals ?? {}, state.channels, loaded ? entries.map((entry) => entry.soul.agent.id) : undefined, env);
1615
+ };
1616
+ /**
1617
+ * Agents that can actually be asked: stored Slack tokens intersected with
1618
+ * the roster. A token that outlived its agent is offered nowhere — the
1619
+ * Slack adapter skips it (`no roster agent with id …`), so approvers
1620
+ * named for it configure a route no call can take. `stratus plugins`
1621
+ * learned this the same way; the Channels menu shows such tokens as
1622
+ * orphans and is the one place they are reachable, to be cleared.
1623
+ *
1624
+ * A roster that failed to load is not evidence of an orphan, so the raw
1625
+ * list stands in that case — the same posture the Channels menu takes,
1626
+ * for the same reason: acting on "no agent has this id" when the roster
1627
+ * could not say who its agents are destroys working configuration.
1628
+ */
1629
+ /**
1630
+ * Agents whose approvers this screen may edit: stored tokens intersected
1631
+ * with the roster, and deliberately *not* gated on the adapter being
1632
+ * installed.
1633
+ *
1634
+ * Reachability and editability are different questions here, and only on
1635
+ * this screen. `offerOptionalPackages()` runs inside `save()`, after the
1636
+ * menu — so on a first install the operator connects Slack under
1637
+ * Channels, opens Approvals, and the package that will be installed
1638
+ * moments later is still absent. Gating the rows on it left the fresh
1639
+ * install with nothing to configure and no way back into the menu,
1640
+ * which is the one path this whole PR exists to make work.
1641
+ *
1642
+ * The *verdict* keeps the package gate, because that is a claim about
1643
+ * what a daemon would do rather than about what may be written now.
1644
+ */
1645
+ const configurableSlackAgents = async () => {
1646
+ const stored = Object.keys(state.channels.slack ?? {});
1647
+ const { entries, loaded } = await channelRoster();
1648
+ if (!loaded) {
1649
+ return stored;
1650
+ }
1651
+ return stored.filter((agentId) => entries.some((entry) => entry.soul.agent.id === agentId));
1652
+ };
1653
+ /**
1654
+ * Trim a menu option to one physical terminal row.
1655
+ *
1656
+ * `selectInteractive` moves the cursor up by the number of options to
1657
+ * redraw, so an option that wraps leaves the rewind short and every
1658
+ * later redraw overwrites the wrong lines. `reserved` is what the
1659
+ * caller's own prefix costs before the text starts.
1660
+ *
1661
+ * Only a TTY has a width to fit into, and piped output keeps the full
1662
+ * text — which is where nothing is redrawn, and also why none of this is
1663
+ * covered by a test: `prompter.isInteractive()` is false whenever
1664
+ * `setupInput` is set, which is every one of them.
1665
+ *
1666
+ * Every option in every menu has this constraint. Fitted here are the
1667
+ * ones this change lengthened or added; a long soul path in the Agent
1668
+ * row can still wrap, and the real fix for that is teaching
1669
+ * `selectInteractive` to count rendered rows.
1670
+ */
1671
+ const fitMenuRow = (text, reserved = 27) => {
1672
+ // `process.stdout` rather than the injected stream, which is typed to
1673
+ // `write` alone — the same place `selectInteractive` reads `isTTY`
1674
+ // from, and only consulted when it is actually driving a terminal.
1675
+ const columns = prompter.isInteractive() ? process.stdout.columns : undefined;
1676
+ if (typeof columns !== 'number' || columns <= 0) {
1677
+ return text;
1678
+ }
1679
+ // The row's own prefix — `menuPrefixWidth`, plus whatever padding the
1680
+ // caller adds — subtracted from the width, so what is left is what the
1681
+ // text may occupy.
1682
+ const budget = columns - reserved;
1683
+ return text.length <= budget ? text : `${text.slice(0, Math.max(budget - 1, 0))}…`;
1684
+ };
1685
+ const chooseApprovals = async () => {
1686
+ while (true) {
1687
+ const mode = state.approvals?.mode ?? 'headless';
1688
+ const connected = await configurableSlackAgents();
1689
+ // Two modes, a row per connected agent when remote, and Back.
1690
+ const reserved = menuPrefixWidth(2 + (mode === 'remote' ? connected.length : 0) + 1);
1691
+ // Static text, and still fitted: with `(current)` shown this row runs
1692
+ // to 76 columns including the selection prefix, so it clears an
1693
+ // 80-column terminal by four and wraps on anything narrower — and a
1694
+ // wrapped row corrupts every redraw, because the rewind counts
1695
+ // options rather than rendered rows. Being static is not being short.
1696
+ const options = [
1697
+ fitMenuRow(`Headless${mode === 'headless' ? ' (current)' : ''} refuse gated calls when nobody is watching`, reserved),
1698
+ fitMenuRow(`Ask in Slack${mode === 'remote' ? ' (current)' : ''} park the turn and ask an approver`, reserved),
1699
+ ];
1700
+ if (mode === 'remote') {
1701
+ for (const agentId of connected) {
1702
+ // The *resolved* answer, through the rule the daemon uses: an
1703
+ // agent with no override of its own inherits the top-level list,
1704
+ // and reading the override alone would report "nobody" for an
1705
+ // agent a global list already covers.
1706
+ const resolved = resolveAgentApprovals(state.approvals, agentId);
1707
+ const own = state.approvals?.agents?.[agentId]?.slackApprovers;
1708
+ const approvers = resolved.slackApprovers ?? [];
1709
+ const label = approvers.length === 0
1710
+ ? '— nobody, so its calls are denied'
1711
+ : own === undefined
1712
+ ? `${approvers.join(', ')} (inherited)`
1713
+ : approvers.join(', ');
1714
+ // Fitted like the top-level row, and for the same reason: several
1715
+ // approvers, or one long agent id, is enough to wrap — and the
1716
+ // redraw rewinds by option count, not by rendered rows. Every
1717
+ // option in every menu has this constraint; these are the ones
1718
+ // this PR adds.
1719
+ options.push(fitMenuRow(` approvers for ${agentId}`.padEnd(26) + label
1720
+ + (approvers.length > 0 && !resolved.slackChannel ? ' · no fallback channel' : ''), reserved));
1721
+ }
1722
+ }
1723
+ options.push('Back');
1724
+ // Said here because it is only true here: tokens are stored, the
1725
+ // adapter is not installed yet, and Save is about to offer it. The
1726
+ // rows are editable regardless — the alternative was a first install
1727
+ // with nothing to configure.
1728
+ const adapterPending = connected.length > 0 && !packageInstalled('@stratusagent/channel-slack', env);
1729
+ // The whole verdict, never trimmed: a footnote is drawn once, outside
1730
+ // the redraw loop, so wrapping costs nothing here — and this is the
1731
+ // screen where the trimmed row's remainder belongs.
1732
+ const verdict = (await approvalsSummary()).join('; ');
1733
+ const footnote = adapterPending
1734
+ ? `${verdict}. @stratusagent/channel-slack is not installed yet — Save & finish offers it, and these approvers apply once it is. ${serveCommand()} brings them online.`
1735
+ : mode === 'remote' && connected.length === 0
1736
+ // The advice comes *after* the verdict, never instead of it: with
1737
+ // the control API up a parked call is already answerable, and
1738
+ // replacing the verdict here told the operator to go connect
1739
+ // Slack while the row above said the API had it. The screen that
1740
+ // changes the mode must not answer differently from the row.
1741
+ ? `${verdict}. No agent is connected to Slack, so connect one under Channels to be asked there.`
1742
+ : verdict;
1743
+ const choice = await prompter.select('Approvals — what happens to a gated call with nobody watching', options, { footnote });
1744
+ if (choice.kind !== 'index' || choice.index === options.length - 1) {
1745
+ return;
1746
+ }
1747
+ if (choice.index === 0) {
1748
+ state.approvals = { ...(state.approvals ?? {}), mode: 'headless' };
1749
+ continue;
1750
+ }
1751
+ if (choice.index === 1) {
1752
+ state.approvals = { ...(state.approvals ?? {}), mode: 'remote' };
1753
+ continue;
1754
+ }
1755
+ const agentId = connected[choice.index - 2];
1756
+ if (!agentId) {
1757
+ continue;
1758
+ }
1759
+ const resolved = resolveAgentApprovals(state.approvals, agentId);
1760
+ const current = resolved.slackApprovers ?? [];
1761
+ // Prefilled with the resolved list, and `ask` returns the prefill for
1762
+ // an empty line — so Enter keeps what is on screen and can never
1763
+ // revoke anything. That matters here more than elsewhere:
1764
+ // `resolveAgentApprovals` reads `agent ?? global` and an empty array
1765
+ // is not nullish, so a written `[]` is the config's way of *excluding*
1766
+ // an agent from the global list, not of leaving it alone.
1767
+ const answer = (await prompter.ask(`Slack user ids who may approve for ${agentId} (comma-separated, e.g. U01ABCDEF; Enter to keep): `, ...(current.length > 0 ? [{ prefill: current.join(', ') }] : []))).trim();
1768
+ const approvers = answer.split(',').map((value) => value.trim()).filter((value) => value.length > 0);
1769
+ const agents = { ...(state.approvals?.agents ?? {}) };
1770
+ const entry = { ...(agents[agentId] ?? {}) };
1771
+ const inheriting = state.approvals?.agents?.[agentId]?.slackApprovers === undefined;
1772
+ const unchanged = approvers.length === current.length
1773
+ && approvers.every((id, index) => id === current[index]);
1774
+ if (approvers.length === 0) {
1775
+ // An empty answer must never *widen*. With a top-level list in play
1776
+ // this writes the explicit `[]` the config reserves for excluding
1777
+ // an agent from it, because deleting the key would hand those
1778
+ // approvers an agent that had been kept from them — either one the
1779
+ // operator excluded on purpose, or one whose narrower list they
1780
+ // just erased. `[]` and an absent key differ here in exactly the
1781
+ // direction that matters, and the previous round fixed the other
1782
+ // half of this: never write `[]` over a list nobody touched.
1783
+ const globalApprovers = state.approvals?.slackApprovers ?? [];
1784
+ const hadOwn = state.approvals?.agents?.[agentId]?.slackApprovers !== undefined;
1785
+ if (globalApprovers.length > 0 || hadOwn) {
1786
+ // `[]` outlives the list it excludes from. With no top-level
1787
+ // approvers today the two look identical, but deleting the key
1788
+ // means the agent silently joins whatever list is added tomorrow
1789
+ // — so an exclusion already on disk is preserved, not tidied
1790
+ // away because it happens to be inert right now.
1791
+ entry.slackApprovers = [];
1792
+ writeLine(streams.stdout, globalApprovers.length > 0
1793
+ ? `${agentId} is excluded from the top-level approvers (${globalApprovers.join(', ')}), so nobody may approve for it and its gated calls are denied.`
1794
+ : `${agentId} has no approvers, so its gated calls are denied — and it stays excluded if a top-level list is added later.`);
1795
+ }
1796
+ else {
1797
+ // Never had one and nothing to inherit: an absent key is what
1798
+ // "unset" looks like, and writing `[]` would invent an exclusion
1799
+ // the operator never asked for.
1800
+ delete entry.slackApprovers;
1801
+ }
1802
+ }
1803
+ else if (inheriting && unchanged) {
1804
+ // Keeping an inherited list must not freeze it: writing the same ids
1805
+ // as this agent's own override would look identical today and stop
1806
+ // tracking the top-level list the operator edits tomorrow.
1807
+ writeLine(streams.stdout, `${agentId} still inherits the top-level approvers (${current.join(', ')}).`);
1808
+ }
1809
+ else {
1810
+ entry.slackApprovers = approvers;
1811
+ }
1812
+ if (entry.slackApprovers !== undefined || resolved.slackApprovers !== undefined) {
1813
+ // The other half of a working route, and the half a fresh install
1814
+ // has no way to guess it needs. A turn that arrived through Slack
1815
+ // is answered in its own thread, but one from a schedule, a
1816
+ // delegation or the control API reaches the adapter with no
1817
+ // destination and is denied undeliverable — so approvers alone
1818
+ // configure approvals for exactly the calls least likely to need
1819
+ // them. `stratus plugins` reports this state; better not to create
1820
+ // it here in the first place.
1821
+ // "Enter to skip" is only true with nothing prefilled. `ask` returns
1822
+ // the prefill for an empty line, so where a channel already resolves
1823
+ // Enter *keeps* it — an operator told otherwise would believe they
1824
+ // had removed a fallback that goes on receiving approval details.
1825
+ // The same correction the approvers prompt above already carries.
1826
+ //
1827
+ // What clearing the line does, though, is decided by whether this
1828
+ // agent *owns* the channel, not by whether one resolves: clearing
1829
+ // an inherited one deletes a key that was never there and the
1830
+ // global goes on resolving. Reading the offer off the resolved
1831
+ // value promised a removal the branch below then refuses — the
1832
+ // prompt and its own outcome disagreeing about the same keypress.
1833
+ const ownChannel = state.approvals?.agents?.[agentId]?.slackChannel;
1834
+ const globalChannel = state.approvals?.slackChannel;
1835
+ const hasChannel = resolved.slackChannel !== undefined;
1836
+ const channelOffer = ownChannel !== undefined
1837
+ ? (globalChannel !== undefined
1838
+ ? `(Enter to keep it; clear the line to fall back to the top-level ${globalChannel}): `
1839
+ : '(Enter to keep it; clear the line to remove it): ')
1840
+ : hasChannel
1841
+ ? '(Enter to go on inheriting it; type another to give this agent its own): '
1842
+ : '(e.g. C0123456, Enter to skip): ';
1843
+ const channelAnswer = (await prompter.ask(`Which Slack channel should ${agentId} ask in when the turn did not start in Slack? ${channelOffer}`, ...(hasChannel ? [{ prefill: resolved.slackChannel }] : []))).trim();
1844
+ // The same inheritance trap as the approvers above, one field over:
1845
+ // the prompt is prefilled with the resolved value, so keeping an
1846
+ // inherited channel would write it as this agent's own and stop it
1847
+ // tracking the top-level one. Both fields need the guard; fixing
1848
+ // only the one under review is how this arrived here twice.
1849
+ const inheritsChannel = ownChannel === undefined;
1850
+ if (channelAnswer.length > 0 && inheritsChannel && channelAnswer === resolved.slackChannel) {
1851
+ writeLine(streams.stdout, `${agentId} still inherits the top-level fallback channel (${channelAnswer}).`);
1852
+ }
1853
+ else if (channelAnswer.length > 0) {
1854
+ entry.slackChannel = channelAnswer;
1855
+ }
1856
+ else if (resolved.slackChannel !== undefined) {
1857
+ // Blanked deliberately: an editable prefill makes this reachable.
1858
+ // What it can mean depends on whether a top-level channel exists,
1859
+ // and only one of the two is "no fallback" — clearing the
1860
+ // override otherwise moves the agent onto the global channel,
1861
+ // which is a different, possibly wider place to post approval
1862
+ // details. Setup says which happened rather than assuming.
1863
+ //
1864
+ // Untested for the same reason the roots deletion above is: an
1865
+ // empty answer where a prefill exists is a TTY-only path, since
1866
+ // the piped prompter returns `line || prefill` and `setupInput`
1867
+ // forces it.
1868
+ delete entry.slackChannel;
1869
+ writeLine(streams.stdout, globalChannel !== undefined
1870
+ ? `${agentId} ${inheritsChannel ? 'still uses' : 'now uses'} the top-level fallback channel (${globalChannel})${inheritsChannel ? '' : ' instead of its own'}. Setup cannot turn the fallback off for one agent — remove approvals.slackChannel to drop it for everyone.`
1871
+ : `${agentId} has no fallback channel now: only turns already in Slack can be asked.`);
1872
+ }
1873
+ else {
1874
+ writeLine(streams.stdout, `No fallback channel for ${agentId}: only turns already in Slack can be asked, and a scheduled or API-started call is denied undeliverable.`);
1875
+ }
1876
+ }
1877
+ agents[agentId] = entry;
1878
+ state.approvals = { ...(state.approvals ?? {}), mode: 'remote', agents };
1879
+ }
1880
+ };
1881
+ const chooseChannels = async () => {
1882
+ while (true) {
1883
+ const { entries: roster, loaded: rosterLoaded } = await channelRoster();
1884
+ const slack = state.channels.slack ?? {};
1885
+ // The roster always holds at least the built-in agent, so there is
1886
+ // no empty state to short-circuit on — and short-circuiting would
1887
+ // strand orphaned tokens, which are only reachable from this list.
1888
+ const options = roster.map((entry) => {
1889
+ const id = entry.soul.agent.id;
1890
+ const status = slack[id] ? '✓ connected' : '— not connected';
1891
+ return `${entry.soul.agent.name} (${id})`.padEnd(34) + status;
1892
+ });
1893
+ // Orphans: tokens whose agent left the roster would otherwise be
1894
+ // invisible here while still being loaded by `stratus serve`.
1895
+ //
1896
+ // Only when the roster actually loaded. "No agent has this id" is a
1897
+ // claim about the roster, and a roster that refused to load cannot
1898
+ // support it — every stored token would look orphaned, and this list
1899
+ // offers to DELETE them. Losing a working agent's Slack credentials
1900
+ // because a different pair of files collided is a far worse outcome
1901
+ // than leaving a real orphan on screen for one run.
1902
+ const orphans = rosterLoaded
1903
+ ? Object.keys(slack).filter((id) => !roster.some((entry) => entry.soul.agent.id === id))
1904
+ : [];
1905
+ if (!rosterLoaded) {
1906
+ writeLine(streams.stderr, 'Warning: no agents are offered while the roster is unreadable — the daemon cannot start either. Fix the roster first.');
1907
+ }
1908
+ for (const id of orphans) {
1909
+ options.push(`${id}`.padEnd(34) + '! tokens without a matching agent');
1910
+ }
1911
+ options.push('Back');
1912
+ const choice = await prompter.select('Channels — Slack (one app per agent: its own name, avatar, and presence)', options, { footnote: `Run \`${serveCommand()}\` afterwards to bring the connected agents online.` });
1913
+ if (choice.kind !== 'index' || choice.index === options.length - 1) {
1914
+ return;
1915
+ }
1916
+ if (choice.index >= roster.length) {
1917
+ // An orphaned entry — offer to clear it.
1918
+ const orphanId = orphans[choice.index - roster.length];
1919
+ if (!orphanId) {
1920
+ return;
1921
+ }
1922
+ const confirm = await prompter.select(`No agent with id ${orphanId} is in the roster.`, [
1923
+ 'Remove these Slack tokens',
1924
+ 'Keep them',
1925
+ ]);
1926
+ if (confirm.kind === 'index' && confirm.index === 0) {
1927
+ const next = { ...slack };
1928
+ delete next[orphanId];
1929
+ state.channels = { ...state.channels, slack: next };
1930
+ state.channelsDirty = true;
1931
+ writeLine(streams.stdout, `Removed the Slack tokens for ${orphanId}.`);
1932
+ }
1933
+ continue;
1934
+ }
1935
+ const entry = roster[choice.index];
1936
+ if (!entry) {
1937
+ return;
1938
+ }
1939
+ const agentId = entry.soul.agent.id;
1940
+ const agentName = entry.soul.agent.name;
1941
+ if (!slack[agentId]) {
1942
+ await connectSlackAgent(agentId, agentName);
1943
+ continue;
1944
+ }
1945
+ const action = await prompter.select(`${agentName} is connected to Slack.`, [
1946
+ 'Replace the tokens (re-run the app setup)',
1947
+ 'Disconnect from Slack (forget the tokens)',
1948
+ 'Back',
1949
+ ]);
1950
+ if (action.kind !== 'index' || action.index === 2) {
1951
+ continue;
1952
+ }
1953
+ if (action.index === 0) {
1954
+ await connectSlackAgent(agentId, agentName);
1955
+ continue;
1956
+ }
1957
+ const next = { ...slack };
1958
+ delete next[agentId];
1959
+ state.channels = { ...state.channels, slack: next };
1960
+ state.channelsDirty = true;
1961
+ writeLine(streams.stdout, `${agentName} is no longer connected to Slack. The Slack app itself still exists — delete it at api.slack.com/apps if you are done with it.`);
1962
+ }
1963
+ };
1964
+ const testRun = async () => {
1965
+ const runtime = await buildTestRuntime();
1966
+ if (!runtime) {
1967
+ return;
1968
+ }
1969
+ writeLine(streams.stdout, `Running a quick hello (${formatRuntimeBanner(runtime).replace('Starting Stratus Agent local loop with ', '')})…`);
1970
+ try {
1971
+ const session = await runSingleLoop('Say hello and introduce yourself in one short sentence.', streams, { events: false, runtime, env });
1972
+ printSessionSummary(session, streams);
1973
+ writeLine(streams.stdout);
1974
+ if (prompter.isInteractive()) {
1975
+ await prompter.ask('Press Enter to return to the menu… ');
1976
+ recentNotices.length = 0;
1977
+ }
1978
+ }
1979
+ catch (error) {
1980
+ const message = error instanceof Error ? error.message : String(error);
1981
+ writeLine(streams.stdout, `Test run failed: ${message}`);
1982
+ if (runtime.provider === 'anthropic' && runtime.authToken && /Claude Code|\b(401|403|429)\b/.test(message)) {
1983
+ writeLine(streams.stdout, 'Subscription runs go through Claude Code. Make sure it is installed (npm install -g @anthropic-ai/claude-code) and signed in (run `claude`), or sign in with an Anthropic API key instead (Providers menu → Claude → API key).');
1984
+ }
1985
+ else if (/\b429\b/.test(message)) {
1986
+ 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.');
1987
+ }
1988
+ else if (/\b(401|403)\b/.test(message)) {
1989
+ writeLine(streams.stdout, 'The provider rejected the credential — re-run sign-in from the Providers menu.');
1990
+ }
1991
+ }
1992
+ };
1993
+ const detectEnvOverride = (primary, chosen, flagName) => {
1994
+ if (!readNonEmptyString(processEnv[primary])) {
1995
+ return undefined;
1996
+ }
1997
+ const envValue = String(processEnv[primary]).trim();
1998
+ if (envValue === chosen) {
1999
+ return undefined;
2000
+ }
2001
+ return {
2002
+ envVar: primary,
2003
+ envValue,
2004
+ ...(flagName ? { flag: `${flagName} ${quoteShellArg(chosen)}` } : {}),
2005
+ };
2006
+ };
2007
+ /**
2008
+ * Whether `stratus dashboard` can work. Read before the offer and set by
2009
+ * it, so the closing suggestions never name a command this machine cannot
2010
+ * run — the failure that sends someone to the logs to find out why.
2011
+ */
2012
+ let dashboardReady = packageInstalled('@stratusagent/control-api', env)
2013
+ && packageInstalled('@stratusagent/dashboard', env);
2014
+ const missingPackageGroups = () => {
2015
+ const groups = [];
2016
+ const slackAgents = Object.keys(state.channels.slack ?? {}).length;
2017
+ if (slackAgents > 0 && !packageInstalled('@stratusagent/channel-slack', env)) {
2018
+ groups.push({
2019
+ id: 'slack',
2020
+ label: 'Slack channel',
2021
+ why: `Slack tokens are stored for ${slackAgents} agent(s), but nothing connects to Slack without it`,
2022
+ packages: ['@stratusagent/channel-slack'],
2023
+ });
2024
+ }
2025
+ // Both, because the dashboard is what was offered: the control API is
2026
+ // the port and the dashboard is the page served on it. Only the missing
2027
+ // half is installed, so accepting this on a machine that already has
2028
+ // the API does not reinstall it.
2029
+ const dashboardPackages = ['@stratusagent/control-api', '@stratusagent/dashboard']
2030
+ .filter((name) => !packageInstalled(name, env));
2031
+ if (dashboardPackages.length > 0) {
2032
+ groups.push({
2033
+ id: 'dashboard',
2034
+ label: 'Web dashboard',
2035
+ why: '`stratus dashboard` needs it, and it opens an authenticated port on 127.0.0.1',
2036
+ packages: dashboardPackages,
2037
+ });
2038
+ }
2039
+ return groups;
2040
+ };
2041
+ /**
2042
+ * Offers the packages above. Declining is a real answer and prints the
2043
+ * command; the control API in particular binds a port, and installing it
2044
+ * is how an operator says they want one open, so this asks rather than
2045
+ * deciding for them.
2046
+ */
2047
+ const offerOptionalPackages = async () => {
2048
+ const groups = missingPackageGroups();
2049
+ if (groups.length === 0) {
2050
+ return;
2051
+ }
2052
+ writeLine(streams.stdout);
2053
+ writeLine(streams.stdout, groups.length === 1
2054
+ ? 'One optional package is not installed:'
2055
+ : `${groups.length} optional packages are not installed:`);
2056
+ for (const group of groups) {
2057
+ writeLine(streams.stdout, ` ${group.packages.join(' ')}`);
2058
+ writeLine(streams.stdout, ` ${group.why}.`);
2059
+ }
2060
+ const answer = await prompter.select('Install now with npm install -g?', groups.length === 1
2061
+ ? ['Install it now', 'Skip']
2062
+ : ['Install all of them now', ...groups.map((group) => `Install the ${group.label} only`), 'Skip']);
2063
+ const chosen = (() => {
2064
+ if (answer.kind !== 'index') {
2065
+ return [];
2066
+ }
2067
+ if (groups.length === 1) {
2068
+ return answer.index === 0 ? groups : [];
2069
+ }
2070
+ if (answer.index === 0) {
2071
+ return groups;
2072
+ }
2073
+ const only = groups[answer.index - 1];
2074
+ return only ? [only] : [];
2075
+ })();
2076
+ // Held before the install rather than derived after it: picking one
2077
+ // group is not a decision about the other, and the daemon starts a few
2078
+ // lines below and warns about exactly what is still missing — the
2079
+ // notice this whole offer exists to pre-empt.
2080
+ const declined = groups.filter((group) => !chosen.includes(group));
2081
+ if (chosen.length > 0) {
2082
+ const packages = chosen.flatMap((group) => group.packages);
2083
+ writeLine(streams.stdout, `Running: npm install -g ${packages.join(' ')}`);
2084
+ // Never fails setup — not even its exit code, unlike the always-on
2085
+ // service step below: the config and credentials are already written,
2086
+ // and a package that did not install is a warning at the next start,
2087
+ // not a broken machine.
2088
+ const result = await (env.packageInstaller ?? defaultPackageInstaller)(packages)
2089
+ .catch((error) => ({
2090
+ ok: false,
2091
+ message: error instanceof Error ? error.message : String(error),
2092
+ }));
2093
+ if (result.ok) {
2094
+ writeLine(streams.stdout, `Installed ${packages.join(' ')}.`);
2095
+ // npm's exit code, not a second resolve: a package written into the
2096
+ // global prefix a moment ago need not be resolvable from THIS
2097
+ // process, whose module resolution was fixed when it started.
2098
+ if (chosen.some((group) => group.id === 'dashboard')) {
2099
+ dashboardReady = true;
2100
+ }
2101
+ }
2102
+ else {
2103
+ writeLine(streams.stderr, `Could not install: ${result.message}`);
2104
+ writeLine(streams.stderr, `Setup is saved either way — run \`npm install -g ${packages.join(' ')}\` yourself.`);
2105
+ }
2106
+ }
2107
+ if (declined.length > 0) {
2108
+ const rest = declined.flatMap((group) => group.packages).join(' ');
2109
+ writeLine(streams.stdout, chosen.length > 0
2110
+ ? `Still missing. Install with: npm install -g ${rest}`
2111
+ : `Skipped. Install them yourself with: npm install -g ${rest}`);
2112
+ }
2113
+ };
2114
+ /**
2115
+ * Write everything the menu decided, then report whether the always-on
2116
+ * service step it performed — installing the service, or removing one the
2117
+ * user chose not to run — succeeded.
2118
+ *
2119
+ * A boolean rather than a throw: the service is the one optional part of
2120
+ * setup and everything else is already on disk by the time it runs, so
2121
+ * failing outright would lose the saved config to a service that can be
2122
+ * installed (or removed) later. The exit code carries it instead — the
2123
+ * same answer `stratus service install` and `stratus service uninstall`
2124
+ * give for the identical failure, which is what a script driving setup
2125
+ * has to be able to see.
2126
+ */
2127
+ const save = async () => {
2128
+ // The always-on service step is the only one here that can fail without
2129
+ // taking the rest of setup with it, so it is the only one the exit code
2130
+ // has to carry. Both signs count: a unit left in place after "do not run
2131
+ // it for me" starts a daemon at login the user asked not to have, which
2132
+ // is no more a successful setup than one that will not come up at all.
2133
+ let serviceStepFailed = false;
2134
+ // Typed as the file rather than as a bag of scalars: the carried blocks
2135
+ // below are objects, and a `Record<string, string | boolean>` was what
2136
+ // made dropping them the path of least resistance.
2137
+ const config = { provider: state.provider };
2138
+ if (state.provider !== 'demo') {
2139
+ config.model = state.model ?? defaultModelFor(state.provider);
2140
+ }
2141
+ if (state.provider === 'openai') {
2142
+ config.baseUrl = state.baseUrl ?? state.credentials.openai?.baseUrl ?? DEFAULT_OPENAI_BASE_URL;
2143
+ }
2144
+ else if (state.provider === 'anthropic' && state.baseUrl) {
2145
+ // A configured anthropic endpoint (a proxy) must survive re-running
2146
+ // setup, or runs silently revert to the official endpoint.
2147
+ config.baseUrl = state.baseUrl;
2148
+ }
2149
+ if (state.apiKeyEnv) {
2150
+ config.apiKeyEnv = state.apiKeyEnv;
2151
+ }
2152
+ if (state.systemPrompt) {
2153
+ config.systemPrompt = state.systemPrompt;
2154
+ }
2155
+ if (state.soulPath) {
2156
+ config.soul = state.soulPath;
2157
+ }
2158
+ if (state.provider !== 'demo' && state.fallbackModel) {
2159
+ config.fallbackModel = state.fallbackModel;
2160
+ config.fallbackProvider = state.fallbackProvider ?? state.provider;
2161
+ if (config.fallbackProvider === 'openai' && state.fallbackBaseUrl) {
2162
+ config.fallbackBaseUrl = state.fallbackBaseUrl;
2163
+ }
2164
+ }
2165
+ if (state.vision !== undefined) {
2166
+ config.vision = state.vision;
2167
+ }
2168
+ if (state.promptCache !== undefined) {
2169
+ config.promptCache = state.promptCache;
2170
+ }
2171
+ if (state.promptCacheTtl !== undefined) {
2172
+ config.promptCacheTtl = state.promptCacheTtl;
2173
+ }
2174
+ // `plugins` and `approvals` have menus above; `api` and `principals`
2175
+ // do not and are written back exactly as they were read. Both cases
2176
+ // land here the same way, because the menus edit this state rather
2177
+ // than the file — so there is nothing to merge, only to not lose.
2178
+ if (state.plugins !== undefined) {
2179
+ config.plugins = state.plugins;
2180
+ }
2181
+ if (state.approvals !== undefined) {
2182
+ config.approvals = state.approvals;
2183
+ }
2184
+ if (state.api !== undefined) {
2185
+ config.api = state.api;
2186
+ }
2187
+ if (state.principals !== undefined) {
2188
+ config.principals = state.principals;
2189
+ }
2190
+ await saveConfigFile(configPath, config);
2191
+ writeLine(streams.stdout);
2192
+ writeLine(streams.stdout, `Wrote ${configPath}`);
2193
+ // A project-local config in this directory outranks the global file for
2194
+ // bare runs started here — say so, and make the suggested command pick
2195
+ // the file that was just written.
2196
+ if (configPath === globalConfigPath(env)) {
2197
+ const shadowPath = path.join(cwd, DEFAULT_CONFIG_FILENAME);
2198
+ try {
2199
+ await readFile(shadowPath, 'utf8');
2200
+ writeLine(streams.stdout, `Note: ${shadowPath} exists and takes precedence over the global config for runs started in this directory.`);
2201
+ writeLine(streams.stdout, 'The suggested commands below include --config so they use what you just saved.');
2202
+ shadowConfigFlag = ` --config ${quoteShellArg(configPath)}`;
2203
+ }
2204
+ catch (error) {
2205
+ if (error.code !== 'ENOENT') {
2206
+ throw error;
2207
+ }
2208
+ }
2209
+ }
2210
+ if (state.credentialsDirty) {
2211
+ await saveCredentials(env, state.credentials);
2212
+ writeLine(streams.stdout, `Saved your sign-in to ${credentialsPath(env)} (readable only by you).`);
2213
+ }
2214
+ if (state.channelsDirty) {
2215
+ // Channel tokens live in their own namespace of the same file; the
2216
+ // writers merge, so this never clobbers the provider sign-in above.
2217
+ await saveChannelCredentials(env, state.channels);
2218
+ const connected = Object.keys(state.channels.slack ?? {}).length;
2219
+ writeLine(streams.stdout, connected > 0
2220
+ ? `Saved Slack tokens for ${connected} agent${connected === 1 ? '' : 's'} to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`
2221
+ : `Removed the stored Slack tokens from ${credentialsPath(env)}.`);
2222
+ }
2223
+ // Before the service block below, deliberately. A package installed
2224
+ // after the daemon starts is invisible to it — installing does not
2225
+ // reload a running process — so offering here is what makes the
2226
+ // LaunchAgent come up with the Slack channel and the control API
2227
+ // already present, instead of warning about them in a log nobody reads
2228
+ // until the dashboard fails.
2229
+ await offerOptionalPackages();
2230
+ // Last, so the daemon starts against the config and credentials that
2231
+ // were just written rather than the ones it would have found a moment
2232
+ // ago. A service failure is reported and never fails setup: the
2233
+ // settings are already saved, and `stratus serve` still works by hand.
2234
+ if (!state.service.install && servicePlatform(serviceEnvFor(env))) {
2235
+ // Opting out has to actually take effect. Skipping the install would
2236
+ // leave a unit from an earlier setup running and enabled at login,
2237
+ // while the menu said "off" — still burning provider usage and still
2238
+ // answering in Slack after an explicit opt-out.
2239
+ const existing = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
2240
+ if (existing?.installed) {
2241
+ // Removal deletes the unit file, so it can reject the same way the
2242
+ // install can. Setup's settings are already written by this point;
2243
+ // the optional service must not take the whole command down.
2244
+ const removed = await uninstallService(serviceEnvFor(env)).catch((error) => ({
2245
+ ok: false,
2246
+ messages: [
2247
+ `Could not remove the always-on service: ${error instanceof Error ? error.message : String(error)}`,
2248
+ `${serviceUnitPath(serviceEnvFor(env))} is still in place — remove it by hand, or it will start again at login.`,
2249
+ ],
2250
+ }));
2251
+ for (const message of removed.messages) {
2252
+ writeLine(removed.ok ? streams.stdout : streams.stderr, message);
2253
+ }
2254
+ if (!removed.ok) {
2255
+ serviceStepFailed = true;
2256
+ }
2257
+ }
2258
+ }
2259
+ else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
2260
+ // The unit is pinned to the file setup just wrote. Its working
2261
+ // directory is the home directory, so discovery from there would
2262
+ // find a different config whenever setup was run with --config or
2263
+ // STRATUS_CONFIG — the daemon would come up on another roster and
2264
+ // leave the Slack apps configured above offline.
2265
+ // installService writes files, so it can reject outright — an
2266
+ // inaccessible ~/Library/LaunchAgents, a read-only home. Letting
2267
+ // that escape would fail setup itself, after the config and
2268
+ // credentials were already saved, when the always-on service is the
2269
+ // one optional part of it.
2270
+ const result = await installService(serviceEnvFor(env), {
2271
+ runAtLogin: state.service.runAtLogin,
2272
+ configPath,
2273
+ }).catch((error) => ({
2274
+ ok: false,
2275
+ messages: [`Could not install the always-on service: ${error instanceof Error ? error.message : String(error)}`],
2276
+ }));
2277
+ for (const message of result.messages) {
2278
+ writeLine(result.ok ? streams.stdout : streams.stderr, message);
2279
+ }
2280
+ if (!result.ok) {
2281
+ writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
2282
+ serviceStepFailed = true;
2283
+ }
2284
+ }
2285
+ writeLine(streams.stdout);
2286
+ // Exported STRATUS_* variables outrank the config file, so warn when one
2287
+ // would make `stratus run` behave differently from what was just saved.
2288
+ const conflicts = [
2289
+ detectEnvOverride('STRATUS_PROVIDER', state.provider, '--provider'),
2290
+ ...(state.provider !== 'demo'
2291
+ ? [detectEnvOverride('STRATUS_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
2292
+ : []),
2293
+ ...(state.provider === 'openai'
2294
+ ? [detectEnvOverride('STRATUS_BASE_URL', state.baseUrl ?? DEFAULT_OPENAI_BASE_URL, '--base-url')]
2295
+ : [detectEnvOverride('STRATUS_BASE_URL', '')]),
2296
+ detectEnvOverride('STRATUS_SYSTEM_PROMPT', state.systemPrompt ?? ''),
2297
+ ].filter((conflict) => conflict !== undefined);
2298
+ for (const conflict of conflicts) {
2299
+ writeLine(streams.stdout, `Note: ${conflict.envVar}=${conflict.envValue} is exported and takes precedence over the config file (run \`unset ${conflict.envVar}\` to clear it).`);
2300
+ }
2301
+ if (conflicts.some((conflict) => conflict.flag)) {
2302
+ writeLine(streams.stdout, 'The suggested commands below include flags so they use what you just configured.');
2303
+ }
2304
+ if (conflicts.length > 0) {
2305
+ writeLine(streams.stdout);
2306
+ }
2307
+ const extraFlags = conflicts.flatMap((conflict) => (conflict.flag ? [` ${conflict.flag}`] : [])).join('');
2308
+ if (state.provider === 'demo') {
2309
+ writeLine(streams.stdout, 'You are ready to go — no account needed. Try:');
2310
+ }
2311
+ else if (readNonEmptyString(processEnv.STRATUS_API_KEY)) {
2312
+ writeLine(streams.stdout, 'STRATUS_API_KEY is exported and takes precedence over your saved sign-in. You are ready to go. Try:');
2313
+ }
2314
+ else if (state.credentials[state.provider]) {
2315
+ writeLine(streams.stdout, `You are ${signInSummary()} — ready to go. Try:`);
2316
+ }
2317
+ else {
2318
+ const keyEnv = state.apiKeyEnv ?? defaultKeyEnvFor(state.provider);
2319
+ if (readNonEmptyString(processEnv[keyEnv])) {
2320
+ writeLine(streams.stdout, `${keyEnv} is set in your environment — you are ready to go. Try:`);
2321
+ }
2322
+ else {
2323
+ writeLine(streams.stdout, 'You are NOT signed in yet — re-run `stratus setup` and pick option 1, or:');
2324
+ writeLine(streams.stdout, ` export ${keyEnv}=your-key`);
2325
+ writeLine(streams.stdout);
2326
+ writeLine(streams.stdout, 'Then try:');
2327
+ }
2328
+ }
2329
+ writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag}${shadowConfigFlag} "say hello"`);
2330
+ if (dashboardReady) {
2331
+ writeLine(streams.stdout, ' stratus dashboard');
2332
+ }
2333
+ return !serviceStepFailed;
2334
+ };
2335
+ try {
2336
+ if (!interactive) {
2337
+ writeLine(streams.stdout, 'Stratus Agent setup');
2338
+ writeLine(streams.stdout, 'Pick a provider, sign in, and create your agent — all from this menu.');
2339
+ }
2340
+ if (envConfigVar && !command.configPath) {
2341
+ writeLine(streams.stdout, `${envConfigVar} is set, so the config will be written to ${configPath}.`);
2342
+ }
2343
+ while (true) {
2344
+ writeLine(streams.stdout);
2345
+ // Awaited before the menu is drawn: the approvals summary reads the
2346
+ // roster, to intersect stored Slack tokens with agents that exist.
2347
+ // Every clause, not the first: a prefix of this sentence has been
2348
+ // wrong twice — the mixed roster where Slack denies one agent and the
2349
+ // control API answers for another reads as a flat denial without the
2350
+ // clause that follows. So the row carries what `stratus plugins`
2351
+ // prints, and where it cannot fit it is *visibly* cut rather than
2352
+ // silently shortened: an ellipsis says there is more, which a chosen
2353
+ // prefix never did. The Approvals screen has the whole thing.
2354
+ //
2355
+ // Cut at all only because `selectInteractive` rewinds the cursor by
2356
+ // `options.length`, one physical row per option — a wrapped row
2357
+ // corrupts every redraw after the first arrow key. Wrapping is not
2358
+ // the cosmetic cost I took it for when I let this line grow.
2359
+ const approvals = fitMenuRow((await approvalsSummary()).join('; '));
2360
+ const choice = await prompter.select('', [
2361
+ `Providers ${providersSummary()}`,
2362
+ `Models ${modelsSummary()}`,
2363
+ `Agent ${agentSummary()}`,
2364
+ `Plugins ${fitMenuRow(pluginsSummary())}`,
2365
+ `Channels ${channelsSummary()}`,
2366
+ `Approvals ${approvals}`,
2367
+ `Always on ${serviceSummary()}`,
2368
+ 'Test run say hello with the current settings',
2369
+ 'Save & finish',
2370
+ ]);
2371
+ // Backing out of the top level (Esc, or the input ending) saves.
2372
+ if (choice.kind !== 'index' || choice.index === 8) {
2373
+ break;
2374
+ }
2375
+ if (choice.index === 0) {
2376
+ await chooseProviders();
2377
+ }
2378
+ else if (choice.index === 1) {
2379
+ await chooseModels();
2380
+ }
2381
+ else if (choice.index === 2) {
2382
+ await chooseAgent();
2383
+ }
2384
+ else if (choice.index === 3) {
2385
+ await choosePlugins();
2386
+ }
2387
+ else if (choice.index === 4) {
2388
+ await chooseChannels();
2389
+ }
2390
+ else if (choice.index === 5) {
2391
+ await chooseApprovals();
2392
+ }
2393
+ else if (choice.index === 6) {
2394
+ await chooseService();
2395
+ }
2396
+ else if (choice.index === 7) {
2397
+ await testRun();
2398
+ }
2399
+ }
2400
+ // Non-zero when the always-on service step failed, matching `stratus
2401
+ // service install` and `stratus service uninstall`. Everything setup
2402
+ // saved is still saved, and the output above says so — but a daemon that
2403
+ // will not come up at login, or one that will after the user said not to
2404
+ // run it, is not a successful setup, and a script that only reads the
2405
+ // exit code had no way to tell.
2406
+ return (await save()) ? 0 : 1;
2407
+ }
2408
+ finally {
2409
+ prompter.close();
2410
+ }
2411
+ };
2412
+ //# sourceMappingURL=setup.js.map