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