@stratusagent/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +7 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1101 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { once } from 'node:events';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@stratusagent/core';
|
|
9
|
+
import { createLocalCommandExecutor, defineLocalCommandTool, } from '@stratusagent/executor-local';
|
|
10
|
+
import { createOpenAICompatibleProvider, createProviderResponseBuilder, defineProvider, } from '@stratusagent/providers';
|
|
11
|
+
import { defineAgent } from '@stratusagent/agents';
|
|
12
|
+
const DEFAULT_CONFIG_FILENAME = 'stratus.config.json';
|
|
13
|
+
const LEGACY_CONFIG_FILENAME = 'stratusclaw.config.json';
|
|
14
|
+
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
|
15
|
+
const DEFAULT_OPENAI_MODEL = 'gpt-4.1-mini';
|
|
16
|
+
const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
|
|
17
|
+
const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
|
|
18
|
+
const HELP_TEXT = `Stratus Agent CLI
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
stratus setup
|
|
22
|
+
stratus run --prompt "Use the demo tool"
|
|
23
|
+
stratus run "Say hello"
|
|
24
|
+
echo "Use the echo tool" | stratus run --stdin
|
|
25
|
+
STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
|
|
26
|
+
stratus run --config ./stratus.config.json --provider openai "Say hello"
|
|
27
|
+
stratus dashboard
|
|
28
|
+
stratus dashboard --port 4123 --host 0.0.0.0 --no-open
|
|
29
|
+
|
|
30
|
+
Commands:
|
|
31
|
+
setup Interactive walkthrough that writes stratus.config.json
|
|
32
|
+
run Execute one local Stratus Agent session
|
|
33
|
+
agent new Create an agent identity (generates a human-ish name + avatar theme)
|
|
34
|
+
dashboard Start the local Stratus Agent dashboard and open it in your browser
|
|
35
|
+
help Show this help message
|
|
36
|
+
|
|
37
|
+
Agent options:
|
|
38
|
+
--name Agent name (omit to have one generated)
|
|
39
|
+
--instructions The agent's persona/instructions
|
|
40
|
+
--format Output format for agent new: text or json
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
--prompt, -p Prompt to send to the local agent loop
|
|
44
|
+
--stdin Read the prompt from stdin
|
|
45
|
+
--provider Provider to use: demo or openai
|
|
46
|
+
--model Model name for real providers (default: gpt-4.1-mini)
|
|
47
|
+
--base-url Override the OpenAI-compatible API base URL
|
|
48
|
+
--config Config file path (run: load settings from it, setup: write it)
|
|
49
|
+
--format Output format: text or json (default: text)
|
|
50
|
+
--no-events Hide event-by-event progress lines in text mode
|
|
51
|
+
--approvals Tool approval mode: always, ask, or never (default: always)
|
|
52
|
+
--max-turns Maximum provider turns per run (default: 8)
|
|
53
|
+
--port Dashboard port, defaults to an open local port
|
|
54
|
+
--host Dashboard host (default: 127.0.0.1)
|
|
55
|
+
--no-open Do not open the browser automatically
|
|
56
|
+
--help, -h Show this help message
|
|
57
|
+
|
|
58
|
+
Config file:
|
|
59
|
+
The CLI looks for ./stratus.config.json by default, or a path from --config / STRATUS_CONFIG.
|
|
60
|
+
Legacy STRATUSCLAW_* env vars and stratusclaw.config.json are still supported for compatibility.
|
|
61
|
+
`;
|
|
62
|
+
const writeLine = (stream, line = '') => {
|
|
63
|
+
stream.write(`${line}\n`);
|
|
64
|
+
};
|
|
65
|
+
const stringifyValue = (value) => {
|
|
66
|
+
if (typeof value === 'string') {
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
return JSON.stringify(value, null, 2);
|
|
70
|
+
};
|
|
71
|
+
const createDemoTool = () => defineLocalCommandTool({
|
|
72
|
+
name: 'demo.echo',
|
|
73
|
+
description: 'Return a tiny transformed summary for CLI demos through a real local process.',
|
|
74
|
+
parameters: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
text: { type: 'string', description: 'Text to echo back through the local process.' },
|
|
78
|
+
},
|
|
79
|
+
required: ['text'],
|
|
80
|
+
},
|
|
81
|
+
async createCommand(input) {
|
|
82
|
+
const text = typeof input.text === 'string' ? input.text : '';
|
|
83
|
+
const normalized = text.trim() || 'empty input';
|
|
84
|
+
const script = `const text = ${JSON.stringify(normalized)}; console.log(JSON.stringify({ received: text, uppercase: text.toUpperCase(), length: text.length }));`;
|
|
85
|
+
return {
|
|
86
|
+
command: process.execPath,
|
|
87
|
+
args: ['-e', script],
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
parseResult(result) {
|
|
91
|
+
return JSON.parse(result.stdout);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const createDemoProvider = () => defineProvider({
|
|
95
|
+
name: 'demo',
|
|
96
|
+
async generate({ session }) {
|
|
97
|
+
const builder = createProviderResponseBuilder();
|
|
98
|
+
const lastMessage = session.messages.at(-1);
|
|
99
|
+
if (lastMessage?.role === 'tool') {
|
|
100
|
+
const result = lastMessage.toolResult;
|
|
101
|
+
if (result?.ok) {
|
|
102
|
+
builder.addText(`The demo.echo tool finished with: ${JSON.stringify(result.output)}`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
builder.addText(`The demo.echo tool did not run (${result?.error ?? 'unknown error'}), so this run ends here.`);
|
|
106
|
+
}
|
|
107
|
+
return builder.done();
|
|
108
|
+
}
|
|
109
|
+
const prompt = [...session.messages].reverse().find((message) => message.role === 'user')?.content?.trim() ?? '';
|
|
110
|
+
const wantsTool = /\b(tool|echo|uppercase|inspect)\b/i.test(prompt);
|
|
111
|
+
builder.addText(`Demo provider ready. Prompt received: ${prompt || '(empty)'}`);
|
|
112
|
+
if (wantsTool) {
|
|
113
|
+
builder.addToolCall({
|
|
114
|
+
id: `${session.id}:call:demo-echo`,
|
|
115
|
+
toolName: 'demo.echo',
|
|
116
|
+
input: { text: prompt },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
builder.addText('No tool call was needed, so this run stays text-only. Mention “tool” or “echo” to trigger the demo tool.');
|
|
121
|
+
}
|
|
122
|
+
return builder.done();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const readPromptFromEnvironment = (env) => (env.stdin ?? '').trim();
|
|
126
|
+
const readPromptFromStdin = async (stdin) => {
|
|
127
|
+
stdin.setEncoding('utf8');
|
|
128
|
+
let data = '';
|
|
129
|
+
for await (const chunk of stdin) {
|
|
130
|
+
data += chunk;
|
|
131
|
+
}
|
|
132
|
+
return data.trim();
|
|
133
|
+
};
|
|
134
|
+
const parseProviderName = (value, label) => {
|
|
135
|
+
if (value === 'demo' || value === 'openai') {
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Unsupported provider in ${label}: ${value}`);
|
|
139
|
+
};
|
|
140
|
+
const readProcessEnv = (env) => env.processEnv ?? process.env;
|
|
141
|
+
const readWorkingDirectory = (env) => env.cwd ?? process.cwd();
|
|
142
|
+
const readOptionValue = (tokens, index, flag) => {
|
|
143
|
+
const value = tokens[index + 1];
|
|
144
|
+
if (!value) {
|
|
145
|
+
throw new Error(`Missing value for ${flag}`);
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
};
|
|
149
|
+
export const parseCommand = (argv, env = {}) => {
|
|
150
|
+
const [command, ...rest] = argv;
|
|
151
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
152
|
+
return { command: 'help' };
|
|
153
|
+
}
|
|
154
|
+
if (command === 'dashboard') {
|
|
155
|
+
let port;
|
|
156
|
+
let host = DEFAULT_DASHBOARD_HOST;
|
|
157
|
+
let openBrowser = true;
|
|
158
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
159
|
+
const token = rest[index];
|
|
160
|
+
if (!token) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (token === '--help' || token === '-h') {
|
|
164
|
+
return { command: 'help' };
|
|
165
|
+
}
|
|
166
|
+
if (token === '--port') {
|
|
167
|
+
const value = Number(readOptionValue(rest, index, '--port'));
|
|
168
|
+
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
|
169
|
+
throw new Error(`Invalid value for --port: ${rest[index + 1] ?? '(missing)'}`);
|
|
170
|
+
}
|
|
171
|
+
port = value;
|
|
172
|
+
index += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (token === '--host') {
|
|
176
|
+
host = readOptionValue(rest, index, '--host');
|
|
177
|
+
index += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (token === '--no-open') {
|
|
181
|
+
openBrowser = false;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`Unknown option: ${token}`);
|
|
185
|
+
}
|
|
186
|
+
return { command: 'dashboard', ...(port !== undefined ? { port } : {}), host, openBrowser };
|
|
187
|
+
}
|
|
188
|
+
if (command === 'agent') {
|
|
189
|
+
const [subcommand, ...agentRest] = rest;
|
|
190
|
+
if (subcommand === '--help' || subcommand === '-h') {
|
|
191
|
+
return { command: 'help' };
|
|
192
|
+
}
|
|
193
|
+
if (subcommand !== 'new') {
|
|
194
|
+
throw new Error(`Unknown agent subcommand: ${subcommand ?? '(missing)'}. Try: stratus agent new`);
|
|
195
|
+
}
|
|
196
|
+
let name;
|
|
197
|
+
let instructions;
|
|
198
|
+
let format = 'text';
|
|
199
|
+
for (let index = 0; index < agentRest.length; index += 1) {
|
|
200
|
+
const token = agentRest[index];
|
|
201
|
+
if (!token) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (token === '--help' || token === '-h') {
|
|
205
|
+
return { command: 'help' };
|
|
206
|
+
}
|
|
207
|
+
if (token === '--name') {
|
|
208
|
+
name = readOptionValue(agentRest, index, '--name');
|
|
209
|
+
index += 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (token === '--instructions') {
|
|
213
|
+
instructions = readOptionValue(agentRest, index, '--instructions');
|
|
214
|
+
index += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (token === '--format') {
|
|
218
|
+
const value = readOptionValue(agentRest, index, '--format');
|
|
219
|
+
if (value !== 'text' && value !== 'json') {
|
|
220
|
+
throw new Error(`Unsupported format: ${value}`);
|
|
221
|
+
}
|
|
222
|
+
format = value;
|
|
223
|
+
index += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
throw new Error(`Unknown option: ${token}`);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
command: 'agent-new',
|
|
230
|
+
...(name ? { name } : {}),
|
|
231
|
+
...(instructions ? { instructions } : {}),
|
|
232
|
+
format,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (command === 'setup') {
|
|
236
|
+
let configPath;
|
|
237
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
238
|
+
const token = rest[index];
|
|
239
|
+
if (!token) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (token === '--help' || token === '-h') {
|
|
243
|
+
return { command: 'help' };
|
|
244
|
+
}
|
|
245
|
+
if (token === '--config') {
|
|
246
|
+
configPath = readOptionValue(rest, index, '--config');
|
|
247
|
+
index += 1;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`Unknown option: ${token}`);
|
|
251
|
+
}
|
|
252
|
+
return { command: 'setup', ...(configPath ? { configPath } : {}) };
|
|
253
|
+
}
|
|
254
|
+
if (command !== 'run') {
|
|
255
|
+
throw new Error(`Unknown command: ${command}`);
|
|
256
|
+
}
|
|
257
|
+
let prompt = '';
|
|
258
|
+
let provider;
|
|
259
|
+
let model;
|
|
260
|
+
let baseUrl;
|
|
261
|
+
let configPath;
|
|
262
|
+
let format = 'text';
|
|
263
|
+
let events = true;
|
|
264
|
+
let approvals = 'always';
|
|
265
|
+
let maxTurns;
|
|
266
|
+
let useStdin = false;
|
|
267
|
+
const positionals = [];
|
|
268
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
269
|
+
const token = rest[index];
|
|
270
|
+
if (!token) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (token === '--help' || token === '-h') {
|
|
274
|
+
return { command: 'help' };
|
|
275
|
+
}
|
|
276
|
+
if (token === '--prompt' || token === '-p') {
|
|
277
|
+
prompt = readOptionValue(rest, index, '--prompt');
|
|
278
|
+
index += 1;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (token === '--stdin') {
|
|
282
|
+
useStdin = true;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (token === '--provider') {
|
|
286
|
+
provider = parseProviderName(readOptionValue(rest, index, '--provider'), '--provider');
|
|
287
|
+
index += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (token === '--model') {
|
|
291
|
+
model = readOptionValue(rest, index, '--model');
|
|
292
|
+
index += 1;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (token === '--base-url') {
|
|
296
|
+
baseUrl = readOptionValue(rest, index, '--base-url');
|
|
297
|
+
index += 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (token === '--config') {
|
|
301
|
+
configPath = readOptionValue(rest, index, '--config');
|
|
302
|
+
index += 1;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (token === '--format') {
|
|
306
|
+
const value = readOptionValue(rest, index, '--format');
|
|
307
|
+
if (value !== 'text' && value !== 'json') {
|
|
308
|
+
throw new Error(`Unsupported format: ${value}`);
|
|
309
|
+
}
|
|
310
|
+
format = value;
|
|
311
|
+
index += 1;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (token === '--no-events') {
|
|
315
|
+
events = false;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (token === '--approvals') {
|
|
319
|
+
const value = readOptionValue(rest, index, '--approvals');
|
|
320
|
+
if (value !== 'always' && value !== 'ask' && value !== 'never') {
|
|
321
|
+
throw new Error(`Unsupported approvals mode: ${value}. Use always, ask, or never.`);
|
|
322
|
+
}
|
|
323
|
+
approvals = value;
|
|
324
|
+
index += 1;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (token === '--max-turns') {
|
|
328
|
+
const value = Number(readOptionValue(rest, index, '--max-turns'));
|
|
329
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
330
|
+
throw new Error(`Invalid value for --max-turns: ${rest[index + 1] ?? '(missing)'}`);
|
|
331
|
+
}
|
|
332
|
+
maxTurns = value;
|
|
333
|
+
index += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (token.startsWith('-')) {
|
|
337
|
+
throw new Error(`Unknown option: ${token}`);
|
|
338
|
+
}
|
|
339
|
+
positionals.push(token);
|
|
340
|
+
}
|
|
341
|
+
if (!prompt) {
|
|
342
|
+
prompt = positionals.join(' ').trim();
|
|
343
|
+
}
|
|
344
|
+
if (!prompt && useStdin) {
|
|
345
|
+
prompt = readPromptFromEnvironment(env);
|
|
346
|
+
}
|
|
347
|
+
if (!prompt) {
|
|
348
|
+
throw new Error('A prompt is required. Pass it with --prompt, --stdin, or as a positional argument.');
|
|
349
|
+
}
|
|
350
|
+
if (approvals === 'ask' && useStdin) {
|
|
351
|
+
throw new Error('--approvals ask cannot be combined with --stdin because both read from standard input.');
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
command: 'run',
|
|
355
|
+
prompt,
|
|
356
|
+
...(provider ? { provider } : {}),
|
|
357
|
+
...(model ? { model } : {}),
|
|
358
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
359
|
+
...(configPath ? { configPath } : {}),
|
|
360
|
+
format,
|
|
361
|
+
events,
|
|
362
|
+
approvals,
|
|
363
|
+
...(maxTurns !== undefined ? { maxTurns } : {}),
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
export const formatEvent = (event) => {
|
|
367
|
+
switch (event.type) {
|
|
368
|
+
case 'session.created':
|
|
369
|
+
return `• session.created ${event.sessionId}`;
|
|
370
|
+
case 'session.updated':
|
|
371
|
+
return `• session.updated ${event.status}`;
|
|
372
|
+
case 'provider.response':
|
|
373
|
+
return `• provider.response ${event.parts.length} part(s)`;
|
|
374
|
+
case 'tool.called':
|
|
375
|
+
return `• tool.called ${event.call.toolName}`;
|
|
376
|
+
case 'tool.completed':
|
|
377
|
+
return `• tool.completed ${event.result.toolName} ok=${String(event.result.ok)}`;
|
|
378
|
+
case 'tool.denied':
|
|
379
|
+
return `• tool.denied ${event.call.toolName}`;
|
|
380
|
+
case 'session.completed':
|
|
381
|
+
return `• session.completed ${event.sessionId}`;
|
|
382
|
+
case 'session.failed':
|
|
383
|
+
return `• session.failed ${event.error}`;
|
|
384
|
+
default:
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
const loadConfigFile = async (configPath) => {
|
|
389
|
+
const raw = await readFile(configPath, 'utf8');
|
|
390
|
+
const parsed = JSON.parse(raw);
|
|
391
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
392
|
+
throw new Error(`Config file must contain a JSON object: ${configPath}`);
|
|
393
|
+
}
|
|
394
|
+
const config = parsed;
|
|
395
|
+
const resolved = {};
|
|
396
|
+
if (typeof config.provider === 'string') {
|
|
397
|
+
resolved.provider = parseProviderName(config.provider, `config ${configPath}`);
|
|
398
|
+
}
|
|
399
|
+
if (typeof config.model === 'string' && config.model.length > 0) {
|
|
400
|
+
resolved.model = config.model;
|
|
401
|
+
}
|
|
402
|
+
if (typeof config.baseUrl === 'string' && config.baseUrl.length > 0) {
|
|
403
|
+
resolved.baseUrl = config.baseUrl;
|
|
404
|
+
}
|
|
405
|
+
if (typeof config.apiKeyEnv === 'string' && config.apiKeyEnv.length > 0) {
|
|
406
|
+
resolved.apiKeyEnv = config.apiKeyEnv;
|
|
407
|
+
}
|
|
408
|
+
if (typeof config.systemPrompt === 'string' && config.systemPrompt.length > 0) {
|
|
409
|
+
resolved.systemPrompt = config.systemPrompt;
|
|
410
|
+
}
|
|
411
|
+
return resolved;
|
|
412
|
+
};
|
|
413
|
+
const resolveConfigPath = async (command, env) => {
|
|
414
|
+
const processEnv = readProcessEnv(env);
|
|
415
|
+
const cwd = readWorkingDirectory(env);
|
|
416
|
+
const explicit = command.configPath ?? processEnv.STRATUS_CONFIG ?? processEnv.STRATUSCLAW_CONFIG;
|
|
417
|
+
if (explicit) {
|
|
418
|
+
return path.resolve(cwd, explicit);
|
|
419
|
+
}
|
|
420
|
+
for (const candidate of [DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME]) {
|
|
421
|
+
const candidatePath = path.join(cwd, candidate);
|
|
422
|
+
try {
|
|
423
|
+
await readFile(candidatePath, 'utf8');
|
|
424
|
+
return candidatePath;
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
const code = error.code;
|
|
428
|
+
if (code !== 'ENOENT') {
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return undefined;
|
|
434
|
+
};
|
|
435
|
+
const readNonEmptyString = (value, map) => {
|
|
436
|
+
if (typeof value !== 'string') {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
const trimmed = value.trim();
|
|
440
|
+
if (trimmed.length === 0) {
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
return map ? map(trimmed) : trimmed;
|
|
444
|
+
};
|
|
445
|
+
export const resolveRuntimeConfig = async (command, env = {}) => {
|
|
446
|
+
const processEnv = readProcessEnv(env);
|
|
447
|
+
const configPath = await resolveConfigPath(command, env);
|
|
448
|
+
const fileConfig = configPath ? await loadConfigFile(configPath) : {};
|
|
449
|
+
const provider = command.provider
|
|
450
|
+
?? readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
|
|
451
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
|
|
452
|
+
?? fileConfig.provider
|
|
453
|
+
?? 'demo';
|
|
454
|
+
if (provider === 'demo') {
|
|
455
|
+
return { provider: 'demo' };
|
|
456
|
+
}
|
|
457
|
+
const model = command.model
|
|
458
|
+
?? readNonEmptyString(processEnv.STRATUS_MODEL)
|
|
459
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
|
|
460
|
+
?? fileConfig.model
|
|
461
|
+
?? DEFAULT_OPENAI_MODEL;
|
|
462
|
+
const baseUrl = command.baseUrl
|
|
463
|
+
?? readNonEmptyString(processEnv.STRATUS_BASE_URL)
|
|
464
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_BASE_URL)
|
|
465
|
+
?? fileConfig.baseUrl
|
|
466
|
+
?? DEFAULT_OPENAI_BASE_URL;
|
|
467
|
+
const apiKeyEnvName = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
|
|
468
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
|
|
469
|
+
?? fileConfig.apiKeyEnv
|
|
470
|
+
?? 'OPENAI_API_KEY';
|
|
471
|
+
const apiKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
472
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
|
|
473
|
+
?? readNonEmptyString(processEnv[String(apiKeyEnvName)]);
|
|
474
|
+
if (!apiKey) {
|
|
475
|
+
throw new Error(`Missing API key for provider=openai. Set STRATUS_API_KEY or ${apiKeyEnvName}.`);
|
|
476
|
+
}
|
|
477
|
+
const resolved = {
|
|
478
|
+
provider: 'openai',
|
|
479
|
+
model: String(model),
|
|
480
|
+
baseUrl: String(baseUrl),
|
|
481
|
+
apiKey: String(apiKey),
|
|
482
|
+
};
|
|
483
|
+
const systemPrompt = readNonEmptyString(processEnv.STRATUS_SYSTEM_PROMPT)
|
|
484
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_SYSTEM_PROMPT)
|
|
485
|
+
?? fileConfig.systemPrompt;
|
|
486
|
+
if (systemPrompt) {
|
|
487
|
+
resolved.systemPrompt = String(systemPrompt);
|
|
488
|
+
}
|
|
489
|
+
if (env.fetch) {
|
|
490
|
+
resolved.fetch = env.fetch;
|
|
491
|
+
}
|
|
492
|
+
return resolved;
|
|
493
|
+
};
|
|
494
|
+
const createRuntimeProvider = (config) => {
|
|
495
|
+
if (config.provider === 'demo') {
|
|
496
|
+
return createDemoProvider();
|
|
497
|
+
}
|
|
498
|
+
return createOpenAICompatibleProvider({
|
|
499
|
+
name: 'openai',
|
|
500
|
+
model: config.model,
|
|
501
|
+
apiKey: config.apiKey,
|
|
502
|
+
baseUrl: config.baseUrl,
|
|
503
|
+
...(config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}),
|
|
504
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
505
|
+
});
|
|
506
|
+
};
|
|
507
|
+
const createApprovalPolicy = (mode, streams, env) => {
|
|
508
|
+
if (mode === 'always') {
|
|
509
|
+
return new AllowAllApprovalPolicy();
|
|
510
|
+
}
|
|
511
|
+
if (mode === 'never') {
|
|
512
|
+
return {
|
|
513
|
+
async approve() {
|
|
514
|
+
return false;
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
async approve({ call }) {
|
|
520
|
+
const input = env.approvalInput ?? process.stdin;
|
|
521
|
+
const readline = createInterface({ input, terminal: false });
|
|
522
|
+
// Prompt on stderr so stdout stays parseable (e.g. --format json).
|
|
523
|
+
streams.stderr.write(`Approve tool call ${call.toolName} with input ${JSON.stringify(call.input)}? [y/N] `);
|
|
524
|
+
try {
|
|
525
|
+
const answer = await new Promise((resolve) => {
|
|
526
|
+
readline.once('line', resolve);
|
|
527
|
+
readline.once('close', () => resolve(''));
|
|
528
|
+
});
|
|
529
|
+
writeLine(streams.stderr);
|
|
530
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
readline.close();
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
};
|
|
538
|
+
export const runSingleLoop = async (prompt, streams, options) => {
|
|
539
|
+
const tools = new ToolRegistry();
|
|
540
|
+
tools.register(createDemoTool());
|
|
541
|
+
const bus = new EventBus({
|
|
542
|
+
onError: (error) => {
|
|
543
|
+
writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
if (options.events ?? true) {
|
|
547
|
+
bus.subscribe(async (event) => {
|
|
548
|
+
const line = formatEvent(event);
|
|
549
|
+
if (line) {
|
|
550
|
+
writeLine(streams.stdout, line);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
const runtimeProvider = createRuntimeProvider(options.runtime);
|
|
555
|
+
const runner = new AgentRunner({
|
|
556
|
+
provider: runtimeProvider,
|
|
557
|
+
tools,
|
|
558
|
+
executor: createLocalCommandExecutor(),
|
|
559
|
+
approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}),
|
|
560
|
+
bus,
|
|
561
|
+
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
562
|
+
});
|
|
563
|
+
await runner.initialize();
|
|
564
|
+
const agent = options.runtime.provider === 'demo'
|
|
565
|
+
? {
|
|
566
|
+
id: 'demo-agent',
|
|
567
|
+
name: 'Demo Agent',
|
|
568
|
+
instructions: 'Keep the loop tiny and readable.',
|
|
569
|
+
}
|
|
570
|
+
: {
|
|
571
|
+
id: 'openai-agent',
|
|
572
|
+
name: 'OpenAI Agent',
|
|
573
|
+
instructions: 'Respond clearly and directly to the user request.',
|
|
574
|
+
};
|
|
575
|
+
const metadata = options.runtime.provider === 'demo'
|
|
576
|
+
? { provider: 'demo' }
|
|
577
|
+
: {
|
|
578
|
+
provider: 'openai',
|
|
579
|
+
model: options.runtime.model,
|
|
580
|
+
baseUrl: options.runtime.baseUrl,
|
|
581
|
+
};
|
|
582
|
+
return runner.run({
|
|
583
|
+
sessionId: randomUUID(),
|
|
584
|
+
agent,
|
|
585
|
+
userMessage: prompt,
|
|
586
|
+
metadata: options.runtime.provider === 'demo'
|
|
587
|
+
? {
|
|
588
|
+
...metadata,
|
|
589
|
+
executor: 'local-command',
|
|
590
|
+
}
|
|
591
|
+
: metadata,
|
|
592
|
+
});
|
|
593
|
+
};
|
|
594
|
+
export const printSessionSummary = (session, streams) => {
|
|
595
|
+
writeLine(streams.stdout);
|
|
596
|
+
writeLine(streams.stdout, 'Messages');
|
|
597
|
+
for (const message of session.messages) {
|
|
598
|
+
if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
|
|
599
|
+
const calls = message.toolCalls
|
|
600
|
+
.map((call) => `${call.toolName}(${JSON.stringify(call.input)})`)
|
|
601
|
+
.join(', ');
|
|
602
|
+
const prefix = message.content ? `${message.content} ` : '';
|
|
603
|
+
writeLine(streams.stdout, `[assistant] ${prefix}→ tool call ${calls}`);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const nameSuffix = message.name ? `:${message.name}` : '';
|
|
607
|
+
const content = message.role === 'tool' ? stringifyValue(JSON.parse(message.content)) : message.content;
|
|
608
|
+
writeLine(streams.stdout, `[${message.role}${nameSuffix}] ${content}`);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
const formatRuntimeBanner = (runtime) => {
|
|
612
|
+
if (runtime.provider === 'demo') {
|
|
613
|
+
return 'Starting Stratus Agent local loop with provider=demo';
|
|
614
|
+
}
|
|
615
|
+
return `Starting Stratus Agent local loop with provider=openai model=${runtime.model}`;
|
|
616
|
+
};
|
|
617
|
+
const escapeHtml = (value) => value
|
|
618
|
+
.replaceAll('&', '&')
|
|
619
|
+
.replaceAll('<', '<')
|
|
620
|
+
.replaceAll('>', '>')
|
|
621
|
+
.replaceAll('"', '"');
|
|
622
|
+
const renderDashboardHtml = (url) => `<!doctype html>
|
|
623
|
+
<html lang="en">
|
|
624
|
+
<head>
|
|
625
|
+
<meta charset="utf-8" />
|
|
626
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
627
|
+
<title>${DASHBOARD_TITLE}</title>
|
|
628
|
+
<style>
|
|
629
|
+
:root { color-scheme: dark; }
|
|
630
|
+
* { box-sizing: border-box; }
|
|
631
|
+
body {
|
|
632
|
+
margin: 0;
|
|
633
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
634
|
+
background: radial-gradient(circle at top, #1f3a5f 0%, #0b1020 45%, #05070f 100%);
|
|
635
|
+
color: #f3f7ff;
|
|
636
|
+
}
|
|
637
|
+
main { max-width: 960px; margin: 0 auto; padding: 40px 20px 72px; }
|
|
638
|
+
.hero, .card { background: rgba(10, 16, 32, 0.74); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 20px; backdrop-filter: blur(12px); }
|
|
639
|
+
.hero { padding: 32px; margin-bottom: 20px; }
|
|
640
|
+
.eyebrow { display: inline-block; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: #93c5fd; }
|
|
641
|
+
h1 { font-size: clamp(32px, 6vw, 56px); line-height: 1; margin: 14px 0 12px; }
|
|
642
|
+
p { color: #cbd5e1; line-height: 1.6; }
|
|
643
|
+
.grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
|
|
644
|
+
.card { padding: 24px; }
|
|
645
|
+
.stat { font-size: 28px; font-weight: 700; margin: 6px 0; }
|
|
646
|
+
code, pre, textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
647
|
+
textarea {
|
|
648
|
+
width: 100%; min-height: 120px; border-radius: 14px; border: 1px solid rgba(148, 163, 184, 0.28);
|
|
649
|
+
background: rgba(15, 23, 42, 0.9); color: #e2e8f0; padding: 14px; resize: vertical;
|
|
650
|
+
}
|
|
651
|
+
button {
|
|
652
|
+
margin-top: 12px; border: 0; border-radius: 999px; padding: 12px 18px; font-weight: 700; cursor: pointer;
|
|
653
|
+
background: linear-gradient(135deg, #60a5fa, #22d3ee); color: #08111f;
|
|
654
|
+
}
|
|
655
|
+
pre {
|
|
656
|
+
margin: 14px 0 0; padding: 14px; border-radius: 14px; overflow: auto;
|
|
657
|
+
background: rgba(2, 6, 23, 0.9); border: 1px solid rgba(148, 163, 184, 0.2); color: #bfdbfe;
|
|
658
|
+
}
|
|
659
|
+
a { color: #7dd3fc; }
|
|
660
|
+
</style>
|
|
661
|
+
</head>
|
|
662
|
+
<body>
|
|
663
|
+
<main>
|
|
664
|
+
<section class="hero">
|
|
665
|
+
<span class="eyebrow">Stratus Agent local dashboard</span>
|
|
666
|
+
<h1>Stratus Agent is up.</h1>
|
|
667
|
+
<p>A tiny dashboard for local testing. It confirms the CLI is reachable, gives you a quick action, and keeps the current repo intent visible.</p>
|
|
668
|
+
<p><strong>Local URL:</strong> <a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>
|
|
669
|
+
</section>
|
|
670
|
+
<section class="grid">
|
|
671
|
+
<article class="card">
|
|
672
|
+
<div class="eyebrow">Status</div>
|
|
673
|
+
<div class="stat">Ready</div>
|
|
674
|
+
<p>The local server is running with Node standard library primitives only.</p>
|
|
675
|
+
</article>
|
|
676
|
+
<article class="card">
|
|
677
|
+
<div class="eyebrow">About</div>
|
|
678
|
+
<div class="stat">Minimal by design</div>
|
|
679
|
+
<p>Use <code>stratus run</code> for the agent loop, or use the tester here to verify browser-to-local requests during development.</p>
|
|
680
|
+
</article>
|
|
681
|
+
</section>
|
|
682
|
+
<section class="card" style="margin-top:20px;">
|
|
683
|
+
<div class="eyebrow">Actionable test</div>
|
|
684
|
+
<h2 style="margin:12px 0 8px;">Echo tester</h2>
|
|
685
|
+
<p>Send a payload to the local dashboard API and inspect the response.</p>
|
|
686
|
+
<textarea id="payload">Hello from Stratus Agent dashboard</textarea>
|
|
687
|
+
<button id="send">POST /api/echo</button>
|
|
688
|
+
<pre id="result">Waiting for input…</pre>
|
|
689
|
+
</section>
|
|
690
|
+
</main>
|
|
691
|
+
<script>
|
|
692
|
+
const button = document.getElementById('send');
|
|
693
|
+
const payload = document.getElementById('payload');
|
|
694
|
+
const result = document.getElementById('result');
|
|
695
|
+
button.addEventListener('click', async () => {
|
|
696
|
+
result.textContent = 'Sending...';
|
|
697
|
+
try {
|
|
698
|
+
const response = await fetch('/api/echo', {
|
|
699
|
+
method: 'POST',
|
|
700
|
+
headers: { 'content-type': 'application/json' },
|
|
701
|
+
body: JSON.stringify({ text: payload.value })
|
|
702
|
+
});
|
|
703
|
+
const data = await response.json();
|
|
704
|
+
result.textContent = JSON.stringify(data, null, 2);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
result.textContent = String(error);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
</script>
|
|
710
|
+
</body>
|
|
711
|
+
</html>`;
|
|
712
|
+
const readBody = async (request) => {
|
|
713
|
+
let data = '';
|
|
714
|
+
for await (const chunk of request) {
|
|
715
|
+
data += chunk.toString();
|
|
716
|
+
}
|
|
717
|
+
return data;
|
|
718
|
+
};
|
|
719
|
+
const sendJson = (response, statusCode, payload) => {
|
|
720
|
+
response.statusCode = statusCode;
|
|
721
|
+
response.setHeader('content-type', 'application/json; charset=utf-8');
|
|
722
|
+
response.end(JSON.stringify(payload, null, 2));
|
|
723
|
+
};
|
|
724
|
+
const sendHtml = (response, html) => {
|
|
725
|
+
response.statusCode = 200;
|
|
726
|
+
response.setHeader('content-type', 'text/html; charset=utf-8');
|
|
727
|
+
response.end(html);
|
|
728
|
+
};
|
|
729
|
+
export const openExternalUrl = async (url) => {
|
|
730
|
+
const platform = process.platform;
|
|
731
|
+
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
732
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
733
|
+
await new Promise((resolve, reject) => {
|
|
734
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: platform !== 'win32' });
|
|
735
|
+
child.once('error', reject);
|
|
736
|
+
child.once('spawn', () => {
|
|
737
|
+
child.unref();
|
|
738
|
+
resolve();
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
};
|
|
742
|
+
export const startDashboardServer = async (options) => {
|
|
743
|
+
const server = createServer(async (request, response) => {
|
|
744
|
+
const method = request.method ?? 'GET';
|
|
745
|
+
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
|
|
746
|
+
if (method === 'GET' && requestUrl.pathname === '/') {
|
|
747
|
+
const address = server.address();
|
|
748
|
+
const port = typeof address === 'object' && address ? address.port : options.port ?? 0;
|
|
749
|
+
const host = typeof address === 'object' && address && address.address ? address.address : options.host;
|
|
750
|
+
sendHtml(response, renderDashboardHtml(`http://${host === '::' ? '127.0.0.1' : host}:${port}`));
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (method === 'GET' && requestUrl.pathname === '/api/status') {
|
|
754
|
+
sendJson(response, 200, {
|
|
755
|
+
ok: true,
|
|
756
|
+
service: 'stratus-dashboard',
|
|
757
|
+
version: '0.1.0',
|
|
758
|
+
now: new Date().toISOString(),
|
|
759
|
+
});
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (method === 'POST' && requestUrl.pathname === '/api/echo') {
|
|
763
|
+
const raw = await readBody(request);
|
|
764
|
+
let text = '';
|
|
765
|
+
if (raw.trim().length > 0) {
|
|
766
|
+
const payload = JSON.parse(raw);
|
|
767
|
+
text = typeof payload.text === 'string' ? payload.text : '';
|
|
768
|
+
}
|
|
769
|
+
sendJson(response, 200, {
|
|
770
|
+
ok: true,
|
|
771
|
+
received: text,
|
|
772
|
+
uppercase: text.toUpperCase(),
|
|
773
|
+
length: text.length,
|
|
774
|
+
});
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
sendJson(response, 404, { ok: false, error: 'Not found' });
|
|
778
|
+
});
|
|
779
|
+
await new Promise((resolve, reject) => {
|
|
780
|
+
server.once('error', reject);
|
|
781
|
+
server.listen(options.port ?? 0, options.host, () => {
|
|
782
|
+
server.off('error', reject);
|
|
783
|
+
resolve();
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
const address = server.address();
|
|
787
|
+
if (!address || typeof address === 'string') {
|
|
788
|
+
throw new Error('Unable to determine dashboard server address.');
|
|
789
|
+
}
|
|
790
|
+
const host = address.address === '::' ? '127.0.0.1' : address.address;
|
|
791
|
+
const url = `http://${host}:${address.port}`;
|
|
792
|
+
return {
|
|
793
|
+
url,
|
|
794
|
+
close: async () => {
|
|
795
|
+
if (!server.listening) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
await new Promise((resolve, reject) => {
|
|
799
|
+
server.close((error) => {
|
|
800
|
+
if (error) {
|
|
801
|
+
reject(error);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
resolve();
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
};
|
|
810
|
+
// Printed commands must survive copy-paste into a shell, so anything outside
|
|
811
|
+
// the safe character set gets single-quoted.
|
|
812
|
+
const quoteShellArg = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
813
|
+
const createSetupPrompter = (streams, env) => {
|
|
814
|
+
const input = env.setupInput ?? process.stdin;
|
|
815
|
+
const readline = createInterface({ input, terminal: false });
|
|
816
|
+
const pendingLines = [];
|
|
817
|
+
let closed = false;
|
|
818
|
+
readline.on('line', (line) => {
|
|
819
|
+
pendingLines.push(line);
|
|
820
|
+
});
|
|
821
|
+
readline.once('close', () => {
|
|
822
|
+
closed = true;
|
|
823
|
+
});
|
|
824
|
+
return {
|
|
825
|
+
async ask(question) {
|
|
826
|
+
streams.stdout.write(question);
|
|
827
|
+
while (pendingLines.length === 0) {
|
|
828
|
+
if (closed) {
|
|
829
|
+
return '';
|
|
830
|
+
}
|
|
831
|
+
await new Promise((resolve) => {
|
|
832
|
+
readline.once('line', () => resolve());
|
|
833
|
+
readline.once('close', () => resolve());
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
const answer = pendingLines.shift() ?? '';
|
|
837
|
+
return answer.trim();
|
|
838
|
+
},
|
|
839
|
+
close() {
|
|
840
|
+
readline.close();
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
};
|
|
844
|
+
const fileExists = async (filePath) => {
|
|
845
|
+
try {
|
|
846
|
+
await readFile(filePath, 'utf8');
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
if (error.code === 'ENOENT') {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
export const runSetup = async (command, streams, env = {}) => {
|
|
857
|
+
const cwd = readWorkingDirectory(env);
|
|
858
|
+
const processEnv = readProcessEnv(env);
|
|
859
|
+
// Mirror resolveConfigPath's precedence (--config, then STRATUS_CONFIG /
|
|
860
|
+
// STRATUSCLAW_CONFIG, then the default filename) so the file written here
|
|
861
|
+
// is the one `stratus run` actually loads afterwards.
|
|
862
|
+
const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG)
|
|
863
|
+
? 'STRATUS_CONFIG'
|
|
864
|
+
: readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
|
|
865
|
+
? 'STRATUSCLAW_CONFIG'
|
|
866
|
+
: undefined;
|
|
867
|
+
const envConfigPath = envConfigVar ? String(processEnv[envConfigVar]).trim() : undefined;
|
|
868
|
+
const chosenPath = command.configPath ?? envConfigPath ?? DEFAULT_CONFIG_FILENAME;
|
|
869
|
+
const configPath = path.resolve(cwd, chosenPath);
|
|
870
|
+
// A path passed via --config is not auto-discovered by `stratus run`, so
|
|
871
|
+
// suggested commands must carry it explicitly. Env-derived paths need no
|
|
872
|
+
// flag because `run` reads the same variable.
|
|
873
|
+
const runConfigFlag = command.configPath ? ` --config ${quoteShellArg(command.configPath)}` : '';
|
|
874
|
+
const prompter = createSetupPrompter(streams, env);
|
|
875
|
+
try {
|
|
876
|
+
writeLine(streams.stdout, 'Stratus Agent setup');
|
|
877
|
+
writeLine(streams.stdout, 'This walkthrough writes a stratus.config.json so `stratus run` works out of the box.');
|
|
878
|
+
writeLine(streams.stdout, 'Press Enter to accept the [default] for any question.');
|
|
879
|
+
if (!command.configPath && envConfigVar) {
|
|
880
|
+
writeLine(streams.stdout, `${envConfigVar} is set, so the config will be written to ${configPath}.`);
|
|
881
|
+
}
|
|
882
|
+
writeLine(streams.stdout);
|
|
883
|
+
if (await fileExists(configPath)) {
|
|
884
|
+
const overwrite = await prompter.ask(`${configPath} already exists. Overwrite it? [y/N] `);
|
|
885
|
+
if (!/^y(es)?$/i.test(overwrite)) {
|
|
886
|
+
writeLine(streams.stdout, 'Keeping the existing config. Nothing was changed.');
|
|
887
|
+
return 0;
|
|
888
|
+
}
|
|
889
|
+
writeLine(streams.stdout);
|
|
890
|
+
}
|
|
891
|
+
const providerAnswer = await prompter.ask('Which provider do you want to use?\n 1) openai — any OpenAI-compatible API (needs an API key)\n 2) demo — built-in provider, no key required\nChoose [1]: ');
|
|
892
|
+
const provider = providerAnswer === '2' || /^demo$/i.test(providerAnswer) ? 'demo' : 'openai';
|
|
893
|
+
// Exported STRATUS_*/STRATUSCLAW_* variables outrank the config file in
|
|
894
|
+
// resolveRuntimeConfig, so any conflicting value would make the suggested
|
|
895
|
+
// bare command behave differently from what setup just wrote. Detect each
|
|
896
|
+
// conflict, warn about it, and add the matching flag so the printed
|
|
897
|
+
// command wins regardless.
|
|
898
|
+
const detectEnvOverride = (primary, legacy, chosen, flagName) => {
|
|
899
|
+
const envVar = readNonEmptyString(processEnv[primary])
|
|
900
|
+
? primary
|
|
901
|
+
: readNonEmptyString(processEnv[legacy])
|
|
902
|
+
? legacy
|
|
903
|
+
: undefined;
|
|
904
|
+
if (!envVar) {
|
|
905
|
+
return undefined;
|
|
906
|
+
}
|
|
907
|
+
const envValue = String(processEnv[envVar]).trim();
|
|
908
|
+
if (envValue === chosen) {
|
|
909
|
+
return undefined;
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
envVar,
|
|
913
|
+
envValue,
|
|
914
|
+
...(flagName ? { flag: `${flagName} ${quoteShellArg(chosen)}` } : {}),
|
|
915
|
+
};
|
|
916
|
+
};
|
|
917
|
+
const warnAboutEnvOverrides = (conflicts) => {
|
|
918
|
+
for (const conflict of conflicts) {
|
|
919
|
+
writeLine(streams.stdout, `Note: ${conflict.envVar}=${conflict.envValue} is exported and takes precedence over the config file (run \`unset ${conflict.envVar}\` to clear it).`);
|
|
920
|
+
}
|
|
921
|
+
if (conflicts.some((conflict) => conflict.flag)) {
|
|
922
|
+
writeLine(streams.stdout, 'The suggested commands below include flags so they use what you just configured.');
|
|
923
|
+
}
|
|
924
|
+
if (conflicts.length > 0) {
|
|
925
|
+
writeLine(streams.stdout);
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
if (provider === 'demo') {
|
|
929
|
+
const conflicts = [
|
|
930
|
+
detectEnvOverride('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER', provider, '--provider'),
|
|
931
|
+
].filter((conflict) => conflict !== undefined);
|
|
932
|
+
const extraFlags = conflicts.map((conflict) => ` ${conflict.flag}`).join('');
|
|
933
|
+
await writeFile(configPath, `${JSON.stringify({ provider: 'demo' }, null, 2)}\n`);
|
|
934
|
+
writeLine(streams.stdout);
|
|
935
|
+
writeLine(streams.stdout, `Wrote ${configPath}`);
|
|
936
|
+
writeLine(streams.stdout);
|
|
937
|
+
warnAboutEnvOverrides(conflicts);
|
|
938
|
+
writeLine(streams.stdout, 'You are ready to go — no API key needed. Try:');
|
|
939
|
+
writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag} "please use the echo tool"`);
|
|
940
|
+
writeLine(streams.stdout, ' stratus dashboard');
|
|
941
|
+
return 0;
|
|
942
|
+
}
|
|
943
|
+
const model = (await prompter.ask(`Model [${DEFAULT_OPENAI_MODEL}]: `)) || DEFAULT_OPENAI_MODEL;
|
|
944
|
+
const baseUrl = (await prompter.ask(`Base URL [${DEFAULT_OPENAI_BASE_URL}]: `)) || DEFAULT_OPENAI_BASE_URL;
|
|
945
|
+
const apiKeyEnv = (await prompter.ask('Environment variable holding your API key [OPENAI_API_KEY]: ')) || 'OPENAI_API_KEY';
|
|
946
|
+
const systemPrompt = await prompter.ask('System prompt (optional, Enter to skip): ');
|
|
947
|
+
const config = { provider, model, baseUrl, apiKeyEnv };
|
|
948
|
+
if (systemPrompt) {
|
|
949
|
+
config.systemPrompt = systemPrompt;
|
|
950
|
+
}
|
|
951
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
952
|
+
const conflicts = [
|
|
953
|
+
detectEnvOverride('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER', provider, '--provider'),
|
|
954
|
+
detectEnvOverride('STRATUS_MODEL', 'STRATUSCLAW_MODEL', model, '--model'),
|
|
955
|
+
detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', baseUrl, '--base-url'),
|
|
956
|
+
// No run flag exists for the system prompt, so this one is warn-only.
|
|
957
|
+
detectEnvOverride('STRATUS_SYSTEM_PROMPT', 'STRATUSCLAW_SYSTEM_PROMPT', systemPrompt),
|
|
958
|
+
].filter((conflict) => conflict !== undefined);
|
|
959
|
+
const extraFlags = conflicts.flatMap((conflict) => (conflict.flag ? [` ${conflict.flag}`] : [])).join('');
|
|
960
|
+
writeLine(streams.stdout);
|
|
961
|
+
writeLine(streams.stdout, `Wrote ${configPath}`);
|
|
962
|
+
writeLine(streams.stdout);
|
|
963
|
+
warnAboutEnvOverrides(conflicts);
|
|
964
|
+
// Readiness must mirror resolveRuntimeConfig's key lookup: a direct
|
|
965
|
+
// STRATUS_API_KEY wins, then an exported *_API_KEY_ENV redirects which
|
|
966
|
+
// variable is read, and only then does the config file's apiKeyEnv apply.
|
|
967
|
+
const keyEnvOverrideVar = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
|
|
968
|
+
? 'STRATUS_API_KEY_ENV'
|
|
969
|
+
: readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
|
|
970
|
+
? 'STRATUSCLAW_API_KEY_ENV'
|
|
971
|
+
: undefined;
|
|
972
|
+
const effectiveApiKeyEnv = keyEnvOverrideVar ? String(processEnv[keyEnvOverrideVar]).trim() : apiKeyEnv;
|
|
973
|
+
const directKeyVar = readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
974
|
+
? 'STRATUS_API_KEY'
|
|
975
|
+
: readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
|
|
976
|
+
? 'STRATUSCLAW_API_KEY'
|
|
977
|
+
: undefined;
|
|
978
|
+
if (keyEnvOverrideVar && effectiveApiKeyEnv !== apiKeyEnv) {
|
|
979
|
+
writeLine(streams.stdout, `Note: ${keyEnvOverrideVar}=${effectiveApiKeyEnv} is exported and takes precedence over the config file's apiKeyEnv,`);
|
|
980
|
+
writeLine(streams.stdout, `so \`stratus run\` will read ${effectiveApiKeyEnv}. Run \`unset ${keyEnvOverrideVar}\` to use ${apiKeyEnv} instead.`);
|
|
981
|
+
writeLine(streams.stdout);
|
|
982
|
+
}
|
|
983
|
+
if (directKeyVar) {
|
|
984
|
+
writeLine(streams.stdout, `${directKeyVar} is set in your environment — you are ready to go. Try:`);
|
|
985
|
+
}
|
|
986
|
+
else if (processEnv[effectiveApiKeyEnv]) {
|
|
987
|
+
writeLine(streams.stdout, `${effectiveApiKeyEnv} is set in your environment — you are ready to go. Try:`);
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
writeLine(streams.stdout, `${effectiveApiKeyEnv} is NOT set in your environment yet. Set it first:`);
|
|
991
|
+
writeLine(streams.stdout, ` export ${effectiveApiKeyEnv}=your-key`);
|
|
992
|
+
writeLine(streams.stdout);
|
|
993
|
+
writeLine(streams.stdout, 'Then try:');
|
|
994
|
+
}
|
|
995
|
+
writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag} "say hello"`);
|
|
996
|
+
writeLine(streams.stdout, ' stratus dashboard');
|
|
997
|
+
return 0;
|
|
998
|
+
}
|
|
999
|
+
finally {
|
|
1000
|
+
prompter.close();
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
export const runAgentNew = (command, streams) => {
|
|
1004
|
+
const agent = defineAgent({
|
|
1005
|
+
...(command.name ? { name: command.name } : {}),
|
|
1006
|
+
...(command.instructions ? { instructions: command.instructions } : {}),
|
|
1007
|
+
});
|
|
1008
|
+
if (command.format === 'json') {
|
|
1009
|
+
writeLine(streams.stdout, JSON.stringify(agent, null, 2));
|
|
1010
|
+
return 0;
|
|
1011
|
+
}
|
|
1012
|
+
writeLine(streams.stdout, `Say hello to ${agent.name}.`);
|
|
1013
|
+
writeLine(streams.stdout);
|
|
1014
|
+
writeLine(streams.stdout, ` id ${agent.id}`);
|
|
1015
|
+
writeLine(streams.stdout, ` avatar ${agent.avatar?.style} theme, hue ${agent.avatar?.hue}, palette ${agent.avatar?.palette.join(' ')}`);
|
|
1016
|
+
if (agent.instructions) {
|
|
1017
|
+
writeLine(streams.stdout, ` soul ${agent.instructions}`);
|
|
1018
|
+
}
|
|
1019
|
+
writeLine(streams.stdout);
|
|
1020
|
+
writeLine(streams.stdout, 'Definition (save this — soul files are coming in #13):');
|
|
1021
|
+
writeLine(streams.stdout, JSON.stringify(agent, null, 2));
|
|
1022
|
+
return 0;
|
|
1023
|
+
};
|
|
1024
|
+
export const runDashboard = async (command, streams, env = {}) => {
|
|
1025
|
+
const handle = await startDashboardServer({
|
|
1026
|
+
host: command.host,
|
|
1027
|
+
...(command.port !== undefined ? { port: command.port } : {}),
|
|
1028
|
+
});
|
|
1029
|
+
writeLine(streams.stdout, `${DASHBOARD_TITLE} ready at ${handle.url}`);
|
|
1030
|
+
writeLine(streams.stdout, 'Press Ctrl+C to stop.');
|
|
1031
|
+
if (command.openBrowser) {
|
|
1032
|
+
try {
|
|
1033
|
+
await (env.openExternal ?? openExternalUrl)(handle.url);
|
|
1034
|
+
writeLine(streams.stdout, 'Opened your default browser.');
|
|
1035
|
+
}
|
|
1036
|
+
catch (error) {
|
|
1037
|
+
writeLine(streams.stderr, `Warning: Could not open the browser automatically: ${error instanceof Error ? error.message : String(error)}`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (env.dashboardAutoShutdownMs !== undefined) {
|
|
1041
|
+
await new Promise((resolve) => setTimeout(resolve, env.dashboardAutoShutdownMs));
|
|
1042
|
+
await handle.close();
|
|
1043
|
+
return 0;
|
|
1044
|
+
}
|
|
1045
|
+
await once(process, 'SIGINT');
|
|
1046
|
+
await handle.close();
|
|
1047
|
+
return 0;
|
|
1048
|
+
};
|
|
1049
|
+
export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
1050
|
+
try {
|
|
1051
|
+
const resolvedEnv = argv.includes('--stdin') && env.stdin === undefined
|
|
1052
|
+
? {
|
|
1053
|
+
...env,
|
|
1054
|
+
stdin: await readPromptFromStdin(env.stdinStream ?? process.stdin),
|
|
1055
|
+
}
|
|
1056
|
+
: env;
|
|
1057
|
+
const command = parseCommand(argv, resolvedEnv);
|
|
1058
|
+
if (command.command === 'help') {
|
|
1059
|
+
writeLine(streams.stdout, HELP_TEXT);
|
|
1060
|
+
return 0;
|
|
1061
|
+
}
|
|
1062
|
+
if (command.command === 'agent-new') {
|
|
1063
|
+
return runAgentNew(command, streams);
|
|
1064
|
+
}
|
|
1065
|
+
if (command.command === 'setup') {
|
|
1066
|
+
return runSetup(command, streams, resolvedEnv);
|
|
1067
|
+
}
|
|
1068
|
+
if (command.command === 'dashboard') {
|
|
1069
|
+
return runDashboard(command, streams, resolvedEnv);
|
|
1070
|
+
}
|
|
1071
|
+
const runtime = await resolveRuntimeConfig(command, resolvedEnv);
|
|
1072
|
+
if (command.format === 'text') {
|
|
1073
|
+
writeLine(streams.stdout, formatRuntimeBanner(runtime));
|
|
1074
|
+
}
|
|
1075
|
+
const session = await runSingleLoop(command.prompt, streams, {
|
|
1076
|
+
events: command.events && command.format === 'text',
|
|
1077
|
+
runtime,
|
|
1078
|
+
approvals: command.approvals,
|
|
1079
|
+
...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
|
|
1080
|
+
env: resolvedEnv,
|
|
1081
|
+
});
|
|
1082
|
+
if (command.format === 'json') {
|
|
1083
|
+
writeLine(streams.stdout, JSON.stringify({
|
|
1084
|
+
provider: runtime.provider,
|
|
1085
|
+
session,
|
|
1086
|
+
}, null, 2));
|
|
1087
|
+
return 0;
|
|
1088
|
+
}
|
|
1089
|
+
printSessionSummary(session, streams);
|
|
1090
|
+
return 0;
|
|
1091
|
+
}
|
|
1092
|
+
catch (error) {
|
|
1093
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1094
|
+
writeLine(streams.stderr, `Error: ${message}`);
|
|
1095
|
+
writeLine(streams.stderr, '');
|
|
1096
|
+
writeLine(streams.stderr, HELP_TEXT);
|
|
1097
|
+
return 1;
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
export { HELP_TEXT, stringifyValue };
|
|
1101
|
+
//# sourceMappingURL=index.js.map
|