@stratusagent/cli 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,16 +1,24 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { randomUUID } from 'node:crypto';
2
10
  import { spawn } from 'node:child_process';
3
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
4
- import { createServer } from 'node:http';
5
- import { once } from 'node:events';
11
+ import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
12
+ import os from 'node:os';
6
13
  import path from 'node:path';
7
14
  import { createInterface } from 'node:readline';
8
- import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@stratusagent/core';
15
+ import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, } from '@stratusagent/core';
9
16
  import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
10
- import { createPermissionPolicy } from '@stratusagent/permissions';
17
+ import { loadPlugins } from '@stratusagent/plugins';
18
+ import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
11
19
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
12
- import { agentIdWithSuffix, createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
13
- import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, loadRosterSouls, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
20
+ import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
21
+ import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
14
22
  import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
15
23
  export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
16
24
  import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
@@ -19,12 +27,50 @@ export { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRec
19
27
  // provider wiring now (the gateway uses the same code); the CLI re-exports
20
28
  // its historical surface so existing importers keep working.
21
29
  export { createFileMemoryStore } from '@stratusagent/state';
22
- // Kept in step with this package's own version by a test, because it is a
23
- // second copy of a number nobody re-reads: it is drawn in the setup header
24
- // and served from the dashboard's /api/status, so drift here misreports the
25
- // running build rather than failing anything.
26
- export const CLI_VERSION = '0.4.0';
27
- const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
30
+ /**
31
+ * Whether spawning npm needs a shell on this platform.
32
+ *
33
+ * On Windows npm is `npm.cmd`, a batch file: a bare `npm` does not exist as
34
+ * an executable there (PATHEXT is a shell's job, not spawn's), and naming
35
+ * the `.cmd` directly has thrown EINVAL since the fix for CVE-2024-27980 —
36
+ * both land in the caller's error handler, so a Windows setup would report
37
+ * that it could not install and leave Slack and the dashboard missing.
38
+ *
39
+ * Every package name reaching the shell is a constant in this file, so
40
+ * there is nothing user-supplied here for it to re-parse.
41
+ */
42
+ export const npmNeedsShell = (platform) => platform === 'win32';
43
+ const defaultPackageInstaller = async (packages) => {
44
+ const { spawn } = await import('node:child_process');
45
+ return new Promise((resolve) => {
46
+ // npm's own output is inherited rather than captured: a global install
47
+ // runs for tens of seconds, and a silent one is indistinguishable from
48
+ // a setup that has hung. stdin is NOT inherited — the setup prompter
49
+ // owns it, and two readers on one stream race for the same bytes.
50
+ const child = spawn('npm', ['install', '-g', ...packages], {
51
+ stdio: ['ignore', 'inherit', 'inherit'],
52
+ shell: npmNeedsShell(process.platform),
53
+ });
54
+ // `error` and `close` can both fire — a spawn that fails still closes.
55
+ let settled = false;
56
+ const finish = (result) => {
57
+ if (settled) {
58
+ return;
59
+ }
60
+ settled = true;
61
+ resolve(result);
62
+ };
63
+ child.once('error', (error) => {
64
+ finish({ ok: false, message: error.message });
65
+ });
66
+ child.once('close', (code) => {
67
+ finish(code === 0
68
+ ? { ok: true, message: '' }
69
+ : { ok: false, message: `npm exited with code ${code ?? 'unknown'}` });
70
+ });
71
+ });
72
+ };
73
+ export const CLI_VERSION = '0.7.0';
28
74
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
29
75
  /**
30
76
  * The range every package declares in its `engines` field. Two floors, not
@@ -77,6 +123,10 @@ Usage:
77
123
  STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
78
124
  stratus run --config ./stratus.config.json --provider openai "Say hello"
79
125
  stratus agents
126
+ stratus agents --gateway http://127.0.0.1:4123
127
+ stratus skill add stratuslabs/skill-code-review
128
+ stratus skill add ./my-skills --skill code-review --agent ava
129
+ stratus skills
80
130
  stratus doctor
81
131
  stratus service install
82
132
  stratus service status
@@ -90,7 +140,9 @@ Commands:
90
140
  subscription or API key), create your agent, connect it to
91
141
  Slack, and test it — settings go to ~/.stratus/config.json,
92
142
  sign-ins and channel tokens to ~/.stratus/credentials.json
93
- (0600)
143
+ (0600). Save & finish offers to install any optional
144
+ package your answers imply (the Slack channel, the control
145
+ API and dashboard) before it starts the daemon
94
146
  chat Talk with your agent — the conversation persists across turns
95
147
  and remembered facts accumulate; /exit or Ctrl+C to leave
96
148
  run Execute one local Stratus Agent session
@@ -100,7 +152,10 @@ Commands:
100
152
  (--idle-timeout <seconds>, --approvals <headless|remote>,
101
153
  --no-events, --no-log-file, --config <path>); everything it
102
154
  says is also written to ~/.stratus/logs, which
103
- "stratus logs" reads
155
+ "stratus logs" reads. With @stratusagent/control-api
156
+ installed it also serves the HTTP + WebSocket control API
157
+ on 127.0.0.1:4123 (--no-api, --api-host, --api-port, or
158
+ the config file's "api" block)
104
159
  service Keep stratusd running under launchd (macOS) or systemd
105
160
  (Linux): install, uninstall, status, start, stop.
106
161
  Installing starts it now and at every login
@@ -109,13 +164,27 @@ Commands:
109
164
  logs Read the daemon's log from any terminal: -f to follow,
110
165
  -n <count> for backlog, --agent / --session to filter,
111
166
  --format json for the raw records
167
+ skill add Install skills from a GitHub repo (owner/repo or URL) or a
168
+ local path into ~/.stratus/skills — whole directories, one
169
+ per skill; works with skills published for other agents
170
+ (skills.sh-style repos). Installed is not enabled: a soul
171
+ opts in via skills:, or pass --agent <id> to enable now.
172
+ --skill <id> picks from a multi-skill repo (repeatable),
173
+ --force replaces an already-installed id
174
+ skills List installed skills and which agents enable each
175
+ (also: stratus skill list)
112
176
  agent new Create an agent identity (generates a human-ish name + avatar theme)
113
177
  agents List your agents: who they are, where their souls live, what
114
- they run on, what they remember (also: stratus agent list)
178
+ they run on, what they remember (also: stratus agent list).
179
+ --gateway <url> asks a running daemon instead of resolving
180
+ locally, authenticating with ~/.stratus/gateway-token
181
+ (override with --token or STRATUS_GATEWAY_TOKEN)
115
182
  doctor Show what a run would use right now — provider, model, soul —
116
183
  and which file or environment variable decided each, then
117
184
  flag anything that would surprise you (--format json)
118
- dashboard Start the local Stratus Agent dashboard and open it in your browser
185
+ dashboard Open the web dashboard: finds a running daemon (or starts one),
186
+ mints a single-use sign-in link, and opens your browser at it.
187
+ Needs @stratusagent/control-api and @stratusagent/dashboard
119
188
  help Show this help message
120
189
 
121
190
  Agent options:
@@ -138,20 +207,46 @@ Options:
138
207
  gated call) or remote (ask in Slack). Default headless, or
139
208
  the config file's "approvals.mode"
140
209
  --max-turns Maximum provider turns per run (default: 8)
141
- --port Dashboard port, defaults to an open local port
142
- --host Dashboard host (default: 127.0.0.1)
210
+ --port dashboard: port for a daemon it starts (default: 4123)
211
+ --host dashboard: host for a daemon it starts (default: 127.0.0.1)
143
212
  --no-open Do not open the browser automatically
213
+ --gateway agents: read the roster from a running daemon's control API
214
+ --token Bearer token for --gateway (default: ~/.stratus/gateway-token)
215
+ --no-api serve: do not serve the control API
216
+ --api-host serve: control API interface (default: 127.0.0.1)
217
+ --api-port serve: control API port (default: 4123, 0 for any free port)
144
218
  --help, -h Show this help message
145
219
 
146
220
  Config file:
147
221
  The CLI looks for ./stratus.config.json first, then a path from --config / STRATUS_CONFIG,
148
222
  then the global ~/.stratus/config.json written by \`stratus setup\`.
149
223
  A "soul" key (or STRATUS_SOUL) points at a soul file so every run uses that agent.
150
- Legacy STRATUSCLAW_* env vars and stratusclaw.config.json are still supported for compatibility.
224
+
225
+ Plugins (tools):
226
+ Capability is optional: install a package, then list it under "plugins" in a
227
+ TRUSTED config (--config, STRATUS_CONFIG, or ~/.stratus/config.json), keyed by
228
+ package name. A plugin runs inside this process, so an auto-discovered
229
+ project-local stratus.config.json may not enable one.
230
+
231
+ "plugins": {
232
+ "@stratusagent/tool-fs": { "enabled": true, "roots": ["~/notes"],
233
+ "agents": { "ava": { "roots": ["~/work/ava"] } } },
234
+ "@stratusagent/tool-web": { "enabled": true }
235
+ }
236
+
237
+ Available: @stratusagent/tool-fs (fs.read/list/search/write),
238
+ @stratusagent/tool-shell (shell.run), @stratusagent/tool-web (web.fetch),
239
+ @stratusagent/tool-browser (browser.goto/read/screenshot/act).
240
+ Installing one grants no agent anything — each soul lists what it may call.
151
241
 
152
242
  Soul files:
153
- A soul file is markdown with frontmatter (name, provider, model, tools, credentials)
243
+ A soul file is markdown with frontmatter (name, provider, model, tools, skills, credentials)
154
244
  followed by the agent's persona in prose. See examples/souls/ava.md.
245
+ "tools" takes exact names or a whole toolset: tools: [fs.read, fs.search] or
246
+ tools: [fs.*]. Omitted means every registered tool.
247
+ "skills" is the same allowlist shape over installed skills (see stratus
248
+ skills), except omitted means none — a skill is enabled per agent, never
249
+ by being installed.
155
250
  `;
156
251
  const writeLine = (stream, line = '') => {
157
252
  stream.write(`${line}\n`);
@@ -185,7 +280,7 @@ export const parseCommand = (argv, env = {}) => {
185
280
  }
186
281
  if (command === 'dashboard') {
187
282
  let port;
188
- let host = DEFAULT_DASHBOARD_HOST;
283
+ let host;
189
284
  let openBrowser = true;
190
285
  for (let index = 0; index < rest.length; index += 1) {
191
286
  const token = rest[index];
@@ -215,7 +310,12 @@ export const parseCommand = (argv, env = {}) => {
215
310
  }
216
311
  throw new Error(`Unknown option: ${token}`);
217
312
  }
218
- return { command: 'dashboard', ...(port !== undefined ? { port } : {}), host, openBrowser };
313
+ return {
314
+ command: 'dashboard',
315
+ ...(port !== undefined ? { port } : {}),
316
+ ...(host !== undefined ? { host } : {}),
317
+ openBrowser,
318
+ };
219
319
  }
220
320
  if (command === 'serve') {
221
321
  const parsed = { command: 'serve', events: true };
@@ -258,6 +358,24 @@ export const parseCommand = (argv, env = {}) => {
258
358
  parsed.logToFile = false;
259
359
  continue;
260
360
  }
361
+ if (token === '--no-api') {
362
+ parsed.api = false;
363
+ continue;
364
+ }
365
+ if (token === '--api-port') {
366
+ const port = Number(readOptionValue(rest, index, '--api-port'));
367
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
368
+ throw new Error(`Invalid value for --api-port: ${rest[index + 1] ?? '(missing)'}`);
369
+ }
370
+ parsed.apiPort = port;
371
+ index += 1;
372
+ continue;
373
+ }
374
+ if (token === '--api-host') {
375
+ parsed.apiHost = readOptionValue(rest, index, '--api-host');
376
+ index += 1;
377
+ continue;
378
+ }
261
379
  throw new Error(`Unknown option: ${token}`);
262
380
  }
263
381
  return parsed;
@@ -326,15 +444,17 @@ export const parseCommand = (argv, env = {}) => {
326
444
  if (command === 'agents' || (command === 'agent' && rest[0] === 'list')) {
327
445
  const agentsRest = command === 'agents' ? rest : rest.slice(1);
328
446
  let format = 'text';
447
+ let gateway;
448
+ let token;
329
449
  for (let index = 0; index < agentsRest.length; index += 1) {
330
- const token = agentsRest[index];
331
- if (!token) {
450
+ const argument = agentsRest[index];
451
+ if (!argument) {
332
452
  continue;
333
453
  }
334
- if (token === '--help' || token === '-h') {
454
+ if (argument === '--help' || argument === '-h') {
335
455
  return { command: 'help' };
336
456
  }
337
- if (token === '--format') {
457
+ if (argument === '--format') {
338
458
  const value = readOptionValue(agentsRest, index, '--format');
339
459
  if (value !== 'text' && value !== 'json') {
340
460
  throw new Error(`Unsupported format: ${value}`);
@@ -343,9 +463,27 @@ export const parseCommand = (argv, env = {}) => {
343
463
  index += 1;
344
464
  continue;
345
465
  }
346
- throw new Error(`Unknown option: ${token}`);
466
+ if (argument === '--gateway') {
467
+ gateway = readOptionValue(agentsRest, index, '--gateway');
468
+ index += 1;
469
+ continue;
470
+ }
471
+ if (argument === '--token') {
472
+ token = readOptionValue(agentsRest, index, '--token');
473
+ index += 1;
474
+ continue;
475
+ }
476
+ throw new Error(`Unknown option: ${argument}`);
477
+ }
478
+ if (token !== undefined && gateway === undefined) {
479
+ throw new Error('--token only applies with --gateway.');
347
480
  }
348
- return { command: 'agents', format };
481
+ return {
482
+ command: 'agents',
483
+ format,
484
+ ...(gateway ? { gateway } : {}),
485
+ ...(token ? { token } : {}),
486
+ };
349
487
  }
350
488
  if (command === 'doctor') {
351
489
  let format = 'text';
@@ -470,6 +608,69 @@ export const parseCommand = (argv, env = {}) => {
470
608
  ...(sessionId ? { sessionId } : {}),
471
609
  };
472
610
  }
611
+ if (command === 'skills' || (command === 'skill' && rest[0] === 'list')) {
612
+ const skillsRest = command === 'skills' ? rest : rest.slice(1);
613
+ for (const token of skillsRest) {
614
+ if (token === '--help' || token === '-h') {
615
+ return { command: 'help' };
616
+ }
617
+ throw new Error(`Unknown option: ${token}`);
618
+ }
619
+ return { command: 'skills' };
620
+ }
621
+ if (command === 'skill') {
622
+ const [subcommand, ...skillRest] = rest;
623
+ if (subcommand === '--help' || subcommand === '-h') {
624
+ return { command: 'help' };
625
+ }
626
+ if (subcommand !== 'add') {
627
+ throw new Error(`Unknown skill subcommand: ${subcommand ?? '(missing)'}. Try: stratus skill add <source>, stratus skills`);
628
+ }
629
+ let source;
630
+ const skillIds = [];
631
+ let force = false;
632
+ let agentId;
633
+ for (let index = 0; index < skillRest.length; index += 1) {
634
+ const token = skillRest[index];
635
+ if (!token) {
636
+ continue;
637
+ }
638
+ if (token === '--help' || token === '-h') {
639
+ return { command: 'help' };
640
+ }
641
+ if (token === '--skill') {
642
+ skillIds.push(readOptionValue(skillRest, index, '--skill'));
643
+ index += 1;
644
+ continue;
645
+ }
646
+ if (token === '--agent') {
647
+ agentId = readOptionValue(skillRest, index, '--agent');
648
+ index += 1;
649
+ continue;
650
+ }
651
+ if (token === '--force') {
652
+ force = true;
653
+ continue;
654
+ }
655
+ if (token.startsWith('--')) {
656
+ throw new Error(`Unknown option: ${token}`);
657
+ }
658
+ if (source !== undefined) {
659
+ throw new Error(`skill add takes one source; got both ${JSON.stringify(source)} and ${JSON.stringify(token)}.`);
660
+ }
661
+ source = token;
662
+ }
663
+ if (source === undefined) {
664
+ throw new Error('skill add needs a source: a GitHub owner/repo, a git URL, or a local path.');
665
+ }
666
+ return {
667
+ command: 'skill-add',
668
+ source,
669
+ ...(skillIds.length > 0 ? { skillIds } : {}),
670
+ ...(force ? { force } : {}),
671
+ ...(agentId !== undefined ? { agentId } : {}),
672
+ };
673
+ }
473
674
  if (command === 'agent') {
474
675
  const [subcommand, ...agentRest] = rest;
475
676
  if (subcommand === '--help' || subcommand === '-h') {
@@ -746,9 +947,8 @@ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
746
947
  return;
747
948
  }
748
949
  // The resolver records the variable that actually won — guessing it here
749
- // would name the wrong one whenever a custom apiKeyEnv or the legacy
750
- // STRATUSCLAW_ prefix supplied the key, sending the reader to unset
751
- // something that was never the cause.
950
+ // would name the wrong one whenever a custom apiKeyEnv supplied the key,
951
+ // sending the reader to unset something that was never the cause.
752
952
  const primary = runtime.provider === 'anthropic' && runtime.apiKey ? runtime.apiKeyEnvVar : undefined;
753
953
  // A fallback demoted the same way costs exactly as much, and only bites
754
954
  // once the primary is already failing — the worst moment to discover it.
@@ -819,6 +1019,15 @@ const createAgentRuntime = async (streams, options) => {
819
1019
  const tools = new ToolRegistry();
820
1020
  tools.register(createDemoTool());
821
1021
  tools.register(createRememberTool(memory));
1022
+ // The same skills the daemon would serve, from the same directory, for
1023
+ // the same reason the plugins below match: a skill that routes in
1024
+ // `stratus run` routes in `stratus serve`, and one that is broken is
1025
+ // broken (and warned about) in both. The runner registers `skill.read`
1026
+ // itself, gated on the soul enabling any skill.
1027
+ const skills = new SkillRegistry();
1028
+ await loadOperatorSkills(runEnv, skills, (line) => {
1029
+ writeLine(streams.stderr, `Warning: ${line}`);
1030
+ });
822
1031
  const bus = new EventBus({
823
1032
  onError: (error) => {
824
1033
  writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -838,6 +1047,31 @@ const createAgentRuntime = async (streams, options) => {
838
1047
  }
839
1048
  });
840
1049
  }
1050
+ // The same plugins the daemon would load, from the same trusted config,
1051
+ // so a tool that works in `stratus run` works in `stratus serve` and a
1052
+ // tool that is missing is missing in both. A local test that silently ran
1053
+ // a different toolset than the daemon would be worse than no local test.
1054
+ const pluginsConfig = await loadServePlugins(runEnv, options.configPath, (line) => {
1055
+ writeLine(streams.stderr, `Warning: ${line}`);
1056
+ });
1057
+ const loadedPlugins = [];
1058
+ if (Object.keys(pluginsConfig).length > 0) {
1059
+ const result = await loadPlugins({
1060
+ config: pluginsConfig,
1061
+ host: {
1062
+ resolve: (specifier) => import.meta.resolve(specifier),
1063
+ import: (specifier) => import(__rewriteRelativeImportExtension(specifier)),
1064
+ },
1065
+ tools,
1066
+ skills,
1067
+ bus,
1068
+ workspaceRoot: workspacesDirPath(runEnv),
1069
+ });
1070
+ loadedPlugins.push(...result.loaded);
1071
+ for (const failure of result.failures) {
1072
+ writeLine(streams.stderr, `Warning: plugin ${failure.package} did not load: ${failure.reason}`);
1073
+ }
1074
+ }
841
1075
  // The Claude Code runtime executes kernel tools by calling back into the
842
1076
  // runner built just below — late-bound because the runner needs the
843
1077
  // provider first.
@@ -857,6 +1091,7 @@ const createAgentRuntime = async (streams, options) => {
857
1091
  executor: createLocalCommandExecutor(),
858
1092
  approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}, options.askApproval),
859
1093
  bus,
1094
+ skills,
860
1095
  memory,
861
1096
  ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
862
1097
  });
@@ -865,6 +1100,13 @@ const createAgentRuntime = async (streams, options) => {
865
1100
  // A soul is a full identity — without one, every provider serves the
866
1101
  // same built-in Stratus persona.
867
1102
  const agent = options.runtime.soul?.agent ?? DEFAULT_STRATUS_AGENT;
1103
+ // The same advisory the daemon gives at roster load, from the same
1104
+ // kernel check: a soul enabling a skill whose `requires:` its `tools:`
1105
+ // does not cover must warn here too, or the local test stays silent
1106
+ // about a configuration `stratus serve` flags.
1107
+ for (const { skill, missing } of missingSkillRequirements(agent, skills)) {
1108
+ writeLine(streams.stderr, `Warning: agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
1109
+ }
868
1110
  const metadata = options.runtime.provider === 'demo'
869
1111
  ? { provider: 'demo', executor: 'local-command' }
870
1112
  : {
@@ -872,16 +1114,36 @@ const createAgentRuntime = async (streams, options) => {
872
1114
  model: options.runtime.model,
873
1115
  ...(options.runtime.provider === 'openai' ? { baseUrl: options.runtime.baseUrl } : {}),
874
1116
  };
875
- return { runner, agent, metadata };
1117
+ // Handed back so a command can release what a plugin acquired — a browser
1118
+ // above all. A one-shot `stratus run` that left a Chromium behind would
1119
+ // be a leak per invocation.
1120
+ const disposePlugins = async () => {
1121
+ for (const plugin of loadedPlugins) {
1122
+ try {
1123
+ await plugin.instance.dispose?.();
1124
+ }
1125
+ catch (error) {
1126
+ writeLine(streams.stderr, `Warning: plugin ${plugin.package} failed to shut down: ${error instanceof Error ? error.message : String(error)}`);
1127
+ }
1128
+ }
1129
+ };
1130
+ return { runner, agent, metadata, disposePlugins };
876
1131
  };
877
1132
  export const runSingleLoop = async (prompt, streams, options) => {
878
- const { runner, agent, metadata } = await createAgentRuntime(streams, options);
879
- return runner.run({
880
- sessionId: randomUUID(),
881
- agent,
882
- userMessage: prompt,
883
- metadata,
884
- });
1133
+ const { runner, agent, metadata, disposePlugins } = await createAgentRuntime(streams, options);
1134
+ try {
1135
+ return await runner.run({
1136
+ sessionId: randomUUID(),
1137
+ agent,
1138
+ userMessage: prompt,
1139
+ metadata,
1140
+ });
1141
+ }
1142
+ finally {
1143
+ // Even when the run failed: a plugin that started a browser started it
1144
+ // before the turn could fail.
1145
+ await disposePlugins();
1146
+ }
885
1147
  };
886
1148
  export const printSessionSummary = (session, streams) => {
887
1149
  writeLine(streams.stdout);
@@ -1003,11 +1265,12 @@ export const runChat = async (command, streams, env = {}) => {
1003
1265
  pendingApproval = resolve;
1004
1266
  });
1005
1267
  };
1006
- const { runner, agent, metadata } = await createAgentRuntime(streams, {
1268
+ const { runner, agent, metadata, disposePlugins } = await createAgentRuntime(streams, {
1007
1269
  runtime,
1008
1270
  approvals: command.approvals,
1009
1271
  askApproval,
1010
1272
  ...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
1273
+ ...(command.configPath ? { configPath: command.configPath } : {}),
1011
1274
  env,
1012
1275
  onEvent: (event) => {
1013
1276
  if (command.events) {
@@ -1109,6 +1372,8 @@ export const runChat = async (command, streams, env = {}) => {
1109
1372
  }
1110
1373
  }
1111
1374
  rl.close();
1375
+ // A chat that held a browser open for an hour still has to put it down.
1376
+ await disposePlugins();
1112
1377
  if (interactive) {
1113
1378
  writeLine(streams.stdout, dim(`Bye — ${agent.name} keeps what they remembered.`));
1114
1379
  }
@@ -1122,118 +1387,6 @@ const formatRuntimeBanner = (runtime) => {
1122
1387
  const fallbackSuffix = runtime.fallback ? ` fallback=${runtime.fallback.model}` : '';
1123
1388
  return `Starting Stratus Agent local loop with provider=${runtime.provider} model=${runtime.model}${fallbackSuffix}${soulSuffix}`;
1124
1389
  };
1125
- const escapeHtml = (value) => value
1126
- .replaceAll('&', '&amp;')
1127
- .replaceAll('<', '&lt;')
1128
- .replaceAll('>', '&gt;')
1129
- .replaceAll('"', '&quot;');
1130
- const renderDashboardHtml = (url) => `<!doctype html>
1131
- <html lang="en">
1132
- <head>
1133
- <meta charset="utf-8" />
1134
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1135
- <title>${DASHBOARD_TITLE}</title>
1136
- <style>
1137
- :root { color-scheme: dark; }
1138
- * { box-sizing: border-box; }
1139
- body {
1140
- margin: 0;
1141
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
1142
- background: radial-gradient(circle at top, #1f3a5f 0%, #0b1020 45%, #05070f 100%);
1143
- color: #f3f7ff;
1144
- }
1145
- main { max-width: 960px; margin: 0 auto; padding: 40px 20px 72px; }
1146
- .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); }
1147
- .hero { padding: 32px; margin-bottom: 20px; }
1148
- .eyebrow { display: inline-block; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: #93c5fd; }
1149
- h1 { font-size: clamp(32px, 6vw, 56px); line-height: 1; margin: 14px 0 12px; }
1150
- p { color: #cbd5e1; line-height: 1.6; }
1151
- .grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
1152
- .card { padding: 24px; }
1153
- .stat { font-size: 28px; font-weight: 700; margin: 6px 0; }
1154
- code, pre, textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
1155
- textarea {
1156
- width: 100%; min-height: 120px; border-radius: 14px; border: 1px solid rgba(148, 163, 184, 0.28);
1157
- background: rgba(15, 23, 42, 0.9); color: #e2e8f0; padding: 14px; resize: vertical;
1158
- }
1159
- button {
1160
- margin-top: 12px; border: 0; border-radius: 999px; padding: 12px 18px; font-weight: 700; cursor: pointer;
1161
- background: linear-gradient(135deg, #60a5fa, #22d3ee); color: #08111f;
1162
- }
1163
- pre {
1164
- margin: 14px 0 0; padding: 14px; border-radius: 14px; overflow: auto;
1165
- background: rgba(2, 6, 23, 0.9); border: 1px solid rgba(148, 163, 184, 0.2); color: #bfdbfe;
1166
- }
1167
- a { color: #7dd3fc; }
1168
- </style>
1169
- </head>
1170
- <body>
1171
- <main>
1172
- <section class="hero">
1173
- <span class="eyebrow">Stratus Agent local dashboard</span>
1174
- <h1>Stratus Agent is up.</h1>
1175
- <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>
1176
- <p><strong>Local URL:</strong> <a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>
1177
- </section>
1178
- <section class="grid">
1179
- <article class="card">
1180
- <div class="eyebrow">Status</div>
1181
- <div class="stat">Ready</div>
1182
- <p>The local server is running with Node standard library primitives only.</p>
1183
- </article>
1184
- <article class="card">
1185
- <div class="eyebrow">About</div>
1186
- <div class="stat">Minimal by design</div>
1187
- <p>Use <code>stratus run</code> for the agent loop, or use the tester here to verify browser-to-local requests during development.</p>
1188
- </article>
1189
- </section>
1190
- <section class="card" style="margin-top:20px;">
1191
- <div class="eyebrow">Actionable test</div>
1192
- <h2 style="margin:12px 0 8px;">Echo tester</h2>
1193
- <p>Send a payload to the local dashboard API and inspect the response.</p>
1194
- <textarea id="payload">Hello from Stratus Agent dashboard</textarea>
1195
- <button id="send">POST /api/echo</button>
1196
- <pre id="result">Waiting for input…</pre>
1197
- </section>
1198
- </main>
1199
- <script>
1200
- const button = document.getElementById('send');
1201
- const payload = document.getElementById('payload');
1202
- const result = document.getElementById('result');
1203
- button.addEventListener('click', async () => {
1204
- result.textContent = 'Sending...';
1205
- try {
1206
- const response = await fetch('/api/echo', {
1207
- method: 'POST',
1208
- headers: { 'content-type': 'application/json' },
1209
- body: JSON.stringify({ text: payload.value })
1210
- });
1211
- const data = await response.json();
1212
- result.textContent = JSON.stringify(data, null, 2);
1213
- } catch (error) {
1214
- result.textContent = String(error);
1215
- }
1216
- });
1217
- </script>
1218
- </body>
1219
- </html>`;
1220
- const readBody = async (request) => {
1221
- let data = '';
1222
- for await (const chunk of request) {
1223
- data += chunk.toString();
1224
- }
1225
- return data;
1226
- };
1227
- const sendJson = (response, statusCode, payload) => {
1228
- response.statusCode = statusCode;
1229
- response.setHeader('content-type', 'application/json; charset=utf-8');
1230
- response.end(JSON.stringify(payload, null, 2));
1231
- };
1232
- const sendHtml = (response, html) => {
1233
- response.statusCode = 200;
1234
- response.setHeader('content-type', 'text/html; charset=utf-8');
1235
- response.end(html);
1236
- };
1237
1390
  export const openExternalUrl = async (url) => {
1238
1391
  const platform = process.platform;
1239
1392
  const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
@@ -1247,74 +1400,6 @@ export const openExternalUrl = async (url) => {
1247
1400
  });
1248
1401
  });
1249
1402
  };
1250
- export const startDashboardServer = async (options) => {
1251
- const server = createServer(async (request, response) => {
1252
- const method = request.method ?? 'GET';
1253
- const requestUrl = new URL(request.url ?? '/', 'http://localhost');
1254
- if (method === 'GET' && requestUrl.pathname === '/') {
1255
- const address = server.address();
1256
- const port = typeof address === 'object' && address ? address.port : options.port ?? 0;
1257
- const host = typeof address === 'object' && address && address.address ? address.address : options.host;
1258
- sendHtml(response, renderDashboardHtml(`http://${host === '::' ? '127.0.0.1' : host}:${port}`));
1259
- return;
1260
- }
1261
- if (method === 'GET' && requestUrl.pathname === '/api/status') {
1262
- sendJson(response, 200, {
1263
- ok: true,
1264
- service: 'stratus-dashboard',
1265
- version: CLI_VERSION,
1266
- now: new Date().toISOString(),
1267
- });
1268
- return;
1269
- }
1270
- if (method === 'POST' && requestUrl.pathname === '/api/echo') {
1271
- const raw = await readBody(request);
1272
- let text = '';
1273
- if (raw.trim().length > 0) {
1274
- const payload = JSON.parse(raw);
1275
- text = typeof payload.text === 'string' ? payload.text : '';
1276
- }
1277
- sendJson(response, 200, {
1278
- ok: true,
1279
- received: text,
1280
- uppercase: text.toUpperCase(),
1281
- length: text.length,
1282
- });
1283
- return;
1284
- }
1285
- sendJson(response, 404, { ok: false, error: 'Not found' });
1286
- });
1287
- await new Promise((resolve, reject) => {
1288
- server.once('error', reject);
1289
- server.listen(options.port ?? 0, options.host, () => {
1290
- server.off('error', reject);
1291
- resolve();
1292
- });
1293
- });
1294
- const address = server.address();
1295
- if (!address || typeof address === 'string') {
1296
- throw new Error('Unable to determine dashboard server address.');
1297
- }
1298
- const host = address.address === '::' ? '127.0.0.1' : address.address;
1299
- const url = `http://${host}:${address.port}`;
1300
- return {
1301
- url,
1302
- close: async () => {
1303
- if (!server.listening) {
1304
- return;
1305
- }
1306
- await new Promise((resolve, reject) => {
1307
- server.close((error) => {
1308
- if (error) {
1309
- reject(error);
1310
- return;
1311
- }
1312
- resolve();
1313
- });
1314
- });
1315
- },
1316
- };
1317
- };
1318
1403
  // Printed commands must survive copy-paste into a shell, so anything outside
1319
1404
  // the safe character set gets single-quoted.
1320
1405
  const quoteShellArg = (value) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
@@ -1622,34 +1707,6 @@ const createSetupPrompter = (streams, env, view) => {
1622
1707
  },
1623
1708
  };
1624
1709
  };
1625
- // Live check that a pasted key actually works, so the user finds out inside
1626
- // setup instead of on their first run.
1627
- const verifyProviderKey = async (provider, key, baseUrl, fetchImpl) => {
1628
- if (typeof fetchImpl !== 'function') {
1629
- return { status: 'unreachable', detail: 'fetch is unavailable' };
1630
- }
1631
- const root = (baseUrl ?? (provider === 'anthropic' ? DEFAULT_ANTHROPIC_BASE_URL : DEFAULT_OPENAI_BASE_URL)).replace(/\/+$/, '');
1632
- const url = provider === 'anthropic' ? `${root}/v1/models` : `${root}/models`;
1633
- const headers = provider === 'anthropic'
1634
- ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' }
1635
- : { authorization: `Bearer ${key}` };
1636
- try {
1637
- const response = await fetchImpl(url, { headers });
1638
- if (response.ok) {
1639
- return { status: 'ok' };
1640
- }
1641
- // Only an explicit auth failure condemns the key. Compatible endpoints
1642
- // (local models, proxies) often lack GET /models entirely — a 404/405
1643
- // there says nothing about the key, so it stays saveable.
1644
- if (response.status === 401 || response.status === 403) {
1645
- return { status: 'rejected', detail: `HTTP ${response.status}` };
1646
- }
1647
- return { status: 'unreachable', detail: `the endpoint did not support a key check (HTTP ${response.status})` };
1648
- }
1649
- catch (error) {
1650
- return { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) };
1651
- }
1652
- };
1653
1710
  // Slack verification talks to the Web API over plain fetch rather than
1654
1711
  // through @stratusagent/channel-slack. Setup must stay usable before that
1655
1712
  // optional package is installed, and the CLI deliberately does not depend
@@ -1748,128 +1805,14 @@ const DEFAULT_SOUL_STARTER = [
1748
1805
  'keep replies short unless the question genuinely needs depth. Use',
1749
1806
  'memory.remember for durable facts about the people you work with.',
1750
1807
  ].join('\n');
1751
- // Shown when live model listing is unavailable (e.g. subscription tokens
1752
- // cannot call the models endpoint, or the machine is offline).
1753
- // Model ids that cannot serve /chat/completions and must not become the
1754
- // default: embeddings, audio, images, moderation, and legacy completions.
1755
- const NON_CHAT_MODEL_PATTERN = /embed|whisper|tts|audio|dall-e|image|moderation|realtime|transcribe|davinci|babbage|curie|(^|[-_])ada([-_]|$)/i;
1756
- const KNOWN_CLAUDE_MODELS = [
1757
- 'claude-opus-5',
1758
- 'claude-sonnet-5',
1759
- 'claude-haiku-4-5',
1760
- 'claude-opus-4-6',
1761
- 'claude-sonnet-4-6',
1762
- ];
1763
- /**
1764
- * The ids a newly created soul must not claim: every id the served roster
1765
- * holds, which is three things and not one directory.
1766
- *
1767
- * - **What the roster files declare.** A filename is not an id: a soul at
1768
- * `renamed.md` may declare `id: ava`, so `ava.md` being free proves
1769
- * nothing — and since a duplicate refuses the whole roster, writing one
1770
- * would hand back an agent whose daemon cannot start.
1771
- * - **The configured default soul**, which the daemon registers whether or
1772
- * not its file lives in the agents directory. It wins a same-id contest
1773
- * with a roster file — `defaultAgentId` replaces the source when the
1774
- * path differs — so a new agent sharing its id is not refused, it is
1775
- * shadowed: created, then undispatchable by id or from Slack, with
1776
- * nothing saying so.
1777
- * - **The reserved `stratus`**, since a roster soul claiming it is skipped
1778
- * at load, so writing one creates an agent that silently never appears.
1779
- *
1780
- * The two readable sources fail **independently**, and what they return is
1781
- * what they know rather than all-or-nothing. A configured soul that is
1782
- * missing or mid-edit does not stop the daemon serving the roster, so it
1783
- * must not discard the roster's claims either: collapsing both to unknown
1784
- * would let this command write the very duplicate that refuses the roster
1785
- * it could have read. `unread` names what could not be answered, so the
1786
- * caller can say which check did not run instead of implying none did.
1787
- */
1788
- const declaredAgentIds = async (env) => {
1789
- const [roster, configured] = await Promise.allSettled([
1790
- loadRosterSouls(env, () => { }),
1791
- // Empty selection: the soul a run started here would resolve, by the
1792
- // same precedence the daemon uses — never a second reading of it.
1793
- resolveConfiguredSoul({}, env),
1794
- ]);
1795
- const ids = new Set([DEFAULT_STRATUS_AGENT.id]);
1796
- const unread = [];
1797
- if (roster.status === 'fulfilled') {
1798
- for (const entry of roster.value) {
1799
- ids.add(entry.soul.agent.id);
1800
- }
1801
- }
1802
- else {
1803
- unread.push('the roster');
1804
- }
1805
- if (configured.status === 'fulfilled') {
1806
- if (configured.value) {
1807
- ids.add(configured.value.soul.agent.id);
1808
- }
1809
- }
1810
- else {
1811
- unread.push('the configured default soul');
1812
- }
1813
- return { ids, unread };
1814
- };
1815
- /**
1816
- * Write a new soul under an id nothing else holds, and return the agent
1817
- * that got written.
1818
- *
1819
- * The name stays theirs, but the id — the soul filename, the memory key,
1820
- * the credential scope — must be unique: the suggestion pool is small, so
1821
- * a repeat name would otherwise share an earlier agent's memory. Two
1822
- * claims have to fail here, and only one of them is a filename:
1823
- *
1824
- * - An id another soul declares, whatever that soul is called on disk.
1825
- * Since a duplicate refuses the whole roster, writing one would leave a
1826
- * daemon that will not start — created by the command meant to help.
1827
- * - The path itself, via `wx`, which makes the claim atomic against a
1828
- * concurrent writer that the roster read above cannot see.
1829
- *
1830
- * On either, the id takes a fresh suffix and we try again — through the
1831
- * shared bound, since appending a suffix to a maxed-out base would build
1832
- * an id the validator refuses, turning a collision into a crash.
1833
- */
1834
- const claimSoulFile = async (env, input, render, note) => {
1835
- const { ids: taken, unread } = await declaredAgentIds(env);
1836
- if (unread.length > 0) {
1837
- note(`Note: could not read ${unread.join(' or ')}, so this id was not checked against the ids it declares.`);
1838
- }
1839
- let agent = defineAgent({ ...(input.name ? { name: input.name } : {}), instructions: input.instructions });
1840
- const baseId = agent.id;
1841
- for (;;) {
1842
- const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
1843
- if (!taken.has(agent.id)) {
1844
- try {
1845
- await writeFile(soulPath, render(agent), { flag: 'wx' });
1846
- return { agent, soulPath };
1847
- }
1848
- catch (error) {
1849
- if (error.code !== 'EEXIST') {
1850
- throw error;
1851
- }
1852
- }
1853
- }
1854
- agent = defineAgent({
1855
- id: agentIdWithSuffix(baseId, randomUUID().slice(0, 4)),
1856
- ...(input.name ? { name: input.name } : { name: agent.name }),
1857
- instructions: input.instructions,
1858
- });
1859
- }
1860
- };
1861
1808
  export const runSetup = async (command, streams, env = {}) => {
1862
1809
  const cwd = readWorkingDirectory(env);
1863
1810
  const processEnv = readProcessEnv(env);
1864
- // Config target: --config, then STRATUS_CONFIG / STRATUSCLAW_CONFIG, then
1865
- // the global ~/.stratus/config.json — the file `stratus run` falls back to
1866
- // from any directory, which is what makes setup a one-time step.
1867
- const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG)
1868
- ? 'STRATUS_CONFIG'
1869
- : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
1870
- ? 'STRATUSCLAW_CONFIG'
1871
- : undefined;
1872
- const envConfigPath = envConfigVar ? String(processEnv[envConfigVar]).trim() : undefined;
1811
+ // Config target: --config, then STRATUS_CONFIG, then the global
1812
+ // ~/.stratus/config.json — the file `stratus run` falls back to from any
1813
+ // directory, which is what makes setup a one-time step.
1814
+ const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG) ? 'STRATUS_CONFIG' : undefined;
1815
+ const envConfigPath = envConfigVar ? String(processEnv.STRATUS_CONFIG).trim() : undefined;
1873
1816
  const configPath = command.configPath
1874
1817
  ? path.resolve(cwd, command.configPath)
1875
1818
  : envConfigPath
@@ -1960,7 +1903,9 @@ export const runSetup = async (command, streams, env = {}) => {
1960
1903
  consumeNotices: () => recentNotices.splice(0),
1961
1904
  });
1962
1905
  const defaultModelFor = (provider) => provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL;
1963
- const defaultKeyEnvFor = (provider) => provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
1906
+ // Widened to CliProviderName only so setup can ask about the provider it
1907
+ // currently has selected, `demo` included; the rule itself is the shared one.
1908
+ const defaultKeyEnvFor = (provider) => defaultApiKeyEnvName(provider === 'openai' ? 'openai' : 'anthropic');
1964
1909
  const credentialLabel = (credential) => credential.type === 'oauth_token' ? 'Claude subscription' : 'API key';
1965
1910
  const providerSignInStatus = (provider) => {
1966
1911
  const credential = state.credentials[provider];
@@ -2180,7 +2125,6 @@ export const runSetup = async (command, streams, env = {}) => {
2180
2125
  }
2181
2126
  const keyEnvSelector = provider === state.provider
2182
2127
  ? readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
2183
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
2184
2128
  ?? state.apiKeyEnv
2185
2129
  : undefined;
2186
2130
  return state.credentials[provider] !== undefined
@@ -2222,85 +2166,16 @@ export const runSetup = async (command, streams, env = {}) => {
2222
2166
  await maybeSwitchDefault('anthropic');
2223
2167
  }
2224
2168
  };
2225
- // Every model the current sign-ins can actually reach, fetched live where
2226
- // possible. Subscription tokens cannot call the models endpoint, so those
2227
- // fall back to the known Claude lineup.
2228
- const collectAvailableModels = async () => {
2229
- const fetchImpl = env.fetch ?? globalThis.fetch;
2230
- const models = [];
2231
- for (const provider of ['anthropic', 'openai']) {
2232
- // Discovery uses the credential a real run would use. STRATUS_API_KEY
2233
- // and a configured apiKeyEnv authenticate the DEFAULT provider only —
2234
- // a secondary provider relies on its own env var or stored sign-in,
2235
- // never the default provider's secret.
2236
- const envKey = (provider === state.provider
2237
- ? readNonEmptyString(processEnv.STRATUS_API_KEY)
2238
- ?? (state.apiKeyEnv ? readNonEmptyString(processEnv[state.apiKeyEnv]) : undefined)
2239
- : undefined)
2240
- ?? readNonEmptyString(processEnv[defaultKeyEnvFor(provider)]);
2241
- const credential = envKey ? undefined : state.credentials[provider];
2242
- const apiKey = envKey ?? (credential?.type === 'api_key' ? credential.value : undefined);
2243
- if (!apiKey && !credential) {
2244
- continue;
2245
- }
2246
- if (provider === 'anthropic') {
2247
- if (!apiKey || typeof fetchImpl !== 'function') {
2248
- // Subscription tokens cannot call the models endpoint.
2249
- models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
2250
- continue;
2251
- }
2252
- // The same endpoint a real run uses: the stored key's bound URL is
2253
- // authoritative, then a configured anthropic base URL (a proxy) —
2254
- // never the official endpoint by accident.
2255
- const anthropicRoot = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
2256
- ?? (state.provider === 'anthropic' ? state.baseUrl : undefined)
2257
- ?? DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/, '');
2258
- try {
2259
- const response = await fetchImpl(`${anthropicRoot}/v1/models?limit=100`, {
2260
- headers: { 'x-api-key': String(apiKey), 'anthropic-version': '2023-06-01' },
2261
- });
2262
- const payload = await response.json();
2263
- const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string');
2264
- models.push(...(ids.length > 0 ? ids : KNOWN_CLAUDE_MODELS).map((id) => ({ provider, id })));
2265
- }
2266
- catch {
2267
- models.push(...KNOWN_CLAUDE_MODELS.map((id) => ({ provider, id })));
2268
- }
2269
- continue;
2270
- }
2271
- if (!apiKey || typeof fetchImpl !== 'function') {
2272
- continue;
2273
- }
2274
- try {
2275
- // A stored key's bound endpoint is authoritative, exactly as at run
2276
- // time; only env-supplied keys follow the default provider's URL.
2277
- const root = ((credential?.type === 'api_key' ? credential.baseUrl : undefined)
2278
- ?? (state.provider === 'openai' ? state.baseUrl : undefined)
2279
- ?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
2280
- const response = await fetchImpl(`${root}/models`, {
2281
- headers: { authorization: `Bearer ${String(apiKey)}` },
2282
- });
2283
- const payload = await response.json();
2284
- const allIds = (payload.data ?? [])
2285
- .map((entry) => entry.id)
2286
- .filter((id) => typeof id === 'string');
2287
- // Runs always call /chat/completions, so embedding, audio, image,
2288
- // moderation, and legacy completion models would save a default
2289
- // that cannot execute. If filtering leaves nothing (an exotic local
2290
- // service), show everything rather than an empty menu.
2291
- const chatIds = allIds.filter((id) => !NON_CHAT_MODEL_PATTERN.test(id));
2292
- const ids = (chatIds.length > 0 ? chatIds : allIds).sort((a, b) => {
2293
- const rank = (id) => (/^gpt/i.test(id) ? 0 : /^o\d/i.test(id) ? 1 : 2);
2294
- return rank(a) - rank(b) || a.localeCompare(b);
2295
- });
2296
- models.push(...ids.map((id) => ({ provider, id })));
2297
- }
2298
- catch {
2299
- // No reachable model list for this provider; skip it.
2300
- }
2301
- }
2302
- return models;
2303
- };
2169
+ // Every model the current sign-ins can actually reach. The rule lives in
2170
+ // @stratusagent/state because the control API answers the same question —
2171
+ // setup passes the selection it is *holding* rather than the saved one, so
2172
+ // a key pasted a moment ago is already in play.
2173
+ const collectAvailableModels = () => collectModels({
2174
+ provider: state.provider,
2175
+ ...(state.baseUrl !== undefined ? { baseUrl: state.baseUrl } : {}),
2176
+ ...(state.apiKeyEnv !== undefined ? { apiKeyEnv: state.apiKeyEnv } : {}),
2177
+ credentials: state.credentials,
2178
+ }, env);
2304
2179
  const pickModel = async (kind) => {
2305
2180
  const available = await collectAvailableModels();
2306
2181
  if (available.length === 0) {
@@ -2446,7 +2321,6 @@ export const runSetup = async (command, streams, env = {}) => {
2446
2321
  const pin = state.provider !== 'demo'
2447
2322
  ? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) }
2448
2323
  : {};
2449
- await mkdir(agentsDirPath(env), { recursive: true });
2450
2324
  const claimed = await claimSoulFile(env, { ...(name ? { name } : {}), instructions: persona }, (agent) => formatSoul({ agent, ...pin }), (message) => writeLine(streams.stdout, message));
2451
2325
  const { agent, soulPath } = claimed;
2452
2326
  state.soulPath = soulPath;
@@ -2507,11 +2381,9 @@ export const runSetup = async (command, streams, env = {}) => {
2507
2381
  // stored key's bound endpoint is authoritative — so the inline test
2508
2382
  // exercises precisely what a real run will use.
2509
2383
  const keyEnv = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
2510
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
2511
2384
  ?? state.apiKeyEnv
2512
2385
  ?? defaultKeyEnvFor(state.provider);
2513
2386
  const envKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
2514
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
2515
2387
  ?? readNonEmptyString(processEnv[String(keyEnv)]);
2516
2388
  const credential = envKey ? undefined : state.credentials[state.provider];
2517
2389
  const boundUrl = credential?.type === 'api_key' ? credential.baseUrl : undefined;
@@ -2670,8 +2542,7 @@ export const runSetup = async (command, streams, env = {}) => {
2670
2542
  // Listing the config soul while `stratus serve` registers the env one
2671
2543
  // would store tokens against an id the adapter then skips.
2672
2544
  const processEnv = readProcessEnv(env);
2673
- const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL)
2674
- ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL);
2545
+ const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL);
2675
2546
  if (typeof envSoul === 'string' && state.soulPath && envSoul !== state.soulPath) {
2676
2547
  warnOnce(`STRATUS_SOUL points at ${envSoul}, which outranks the configured ${state.soulPath} — Channels lists what a run would actually use`);
2677
2548
  }
@@ -2856,25 +2727,126 @@ export const runSetup = async (command, streams, env = {}) => {
2856
2727
  }
2857
2728
  }
2858
2729
  };
2859
- const detectEnvOverride = (primary, legacy, chosen, flagName) => {
2860
- const envVar = readNonEmptyString(processEnv[primary])
2861
- ? primary
2862
- : readNonEmptyString(processEnv[legacy])
2863
- ? legacy
2864
- : undefined;
2865
- if (!envVar) {
2730
+ const detectEnvOverride = (primary, chosen, flagName) => {
2731
+ if (!readNonEmptyString(processEnv[primary])) {
2866
2732
  return undefined;
2867
2733
  }
2868
- const envValue = String(processEnv[envVar]).trim();
2734
+ const envValue = String(processEnv[primary]).trim();
2869
2735
  if (envValue === chosen) {
2870
2736
  return undefined;
2871
2737
  }
2872
2738
  return {
2873
- envVar,
2739
+ envVar: primary,
2874
2740
  envValue,
2875
2741
  ...(flagName ? { flag: `${flagName} ${quoteShellArg(chosen)}` } : {}),
2876
2742
  };
2877
2743
  };
2744
+ /**
2745
+ * Whether `stratus dashboard` can work. Read before the offer and set by
2746
+ * it, so the closing suggestions never name a command this machine cannot
2747
+ * run — the failure that sends someone to the logs to find out why.
2748
+ */
2749
+ let dashboardReady = packageInstalled('@stratusagent/control-api', env)
2750
+ && packageInstalled('@stratusagent/dashboard', env);
2751
+ const missingPackageGroups = () => {
2752
+ const groups = [];
2753
+ const slackAgents = Object.keys(state.channels.slack ?? {}).length;
2754
+ if (slackAgents > 0 && !packageInstalled('@stratusagent/channel-slack', env)) {
2755
+ groups.push({
2756
+ id: 'slack',
2757
+ label: 'Slack channel',
2758
+ why: `Slack tokens are stored for ${slackAgents} agent(s), but nothing connects to Slack without it`,
2759
+ packages: ['@stratusagent/channel-slack'],
2760
+ });
2761
+ }
2762
+ // Both, because the dashboard is what was offered: the control API is
2763
+ // the port and the dashboard is the page served on it. Only the missing
2764
+ // half is installed, so accepting this on a machine that already has
2765
+ // the API does not reinstall it.
2766
+ const dashboardPackages = ['@stratusagent/control-api', '@stratusagent/dashboard']
2767
+ .filter((name) => !packageInstalled(name, env));
2768
+ if (dashboardPackages.length > 0) {
2769
+ groups.push({
2770
+ id: 'dashboard',
2771
+ label: 'Web dashboard',
2772
+ why: '`stratus dashboard` needs it, and it opens an authenticated port on 127.0.0.1',
2773
+ packages: dashboardPackages,
2774
+ });
2775
+ }
2776
+ return groups;
2777
+ };
2778
+ /**
2779
+ * Offers the packages above. Declining is a real answer and prints the
2780
+ * command; the control API in particular binds a port, and installing it
2781
+ * is how an operator says they want one open, so this asks rather than
2782
+ * deciding for them.
2783
+ */
2784
+ const offerOptionalPackages = async () => {
2785
+ const groups = missingPackageGroups();
2786
+ if (groups.length === 0) {
2787
+ return;
2788
+ }
2789
+ writeLine(streams.stdout);
2790
+ writeLine(streams.stdout, groups.length === 1
2791
+ ? 'One optional package is not installed:'
2792
+ : `${groups.length} optional packages are not installed:`);
2793
+ for (const group of groups) {
2794
+ writeLine(streams.stdout, ` ${group.packages.join(' ')}`);
2795
+ writeLine(streams.stdout, ` ${group.why}.`);
2796
+ }
2797
+ const answer = await prompter.select('Install now with npm install -g?', groups.length === 1
2798
+ ? ['Install it now', 'Skip']
2799
+ : ['Install all of them now', ...groups.map((group) => `Install the ${group.label} only`), 'Skip']);
2800
+ const chosen = (() => {
2801
+ if (answer.kind !== 'index') {
2802
+ return [];
2803
+ }
2804
+ if (groups.length === 1) {
2805
+ return answer.index === 0 ? groups : [];
2806
+ }
2807
+ if (answer.index === 0) {
2808
+ return groups;
2809
+ }
2810
+ const only = groups[answer.index - 1];
2811
+ return only ? [only] : [];
2812
+ })();
2813
+ // Held before the install rather than derived after it: picking one
2814
+ // group is not a decision about the other, and the daemon starts a few
2815
+ // lines below and warns about exactly what is still missing — the
2816
+ // notice this whole offer exists to pre-empt.
2817
+ const declined = groups.filter((group) => !chosen.includes(group));
2818
+ if (chosen.length > 0) {
2819
+ const packages = chosen.flatMap((group) => group.packages);
2820
+ writeLine(streams.stdout, `Running: npm install -g ${packages.join(' ')}`);
2821
+ // Never fails setup, for the same reason the service install does not:
2822
+ // the config and credentials are already written, and a package that
2823
+ // did not install is a warning at the next start, not a broken machine.
2824
+ const result = await (env.packageInstaller ?? defaultPackageInstaller)(packages)
2825
+ .catch((error) => ({
2826
+ ok: false,
2827
+ message: error instanceof Error ? error.message : String(error),
2828
+ }));
2829
+ if (result.ok) {
2830
+ writeLine(streams.stdout, `Installed ${packages.join(' ')}.`);
2831
+ // npm's exit code, not a second resolve: a package written into the
2832
+ // global prefix a moment ago need not be resolvable from THIS
2833
+ // process, whose module resolution was fixed when it started.
2834
+ if (chosen.some((group) => group.id === 'dashboard')) {
2835
+ dashboardReady = true;
2836
+ }
2837
+ }
2838
+ else {
2839
+ writeLine(streams.stderr, `Could not install: ${result.message}`);
2840
+ writeLine(streams.stderr, `Setup is saved either way — run \`npm install -g ${packages.join(' ')}\` yourself.`);
2841
+ }
2842
+ }
2843
+ if (declined.length > 0) {
2844
+ const rest = declined.flatMap((group) => group.packages).join(' ');
2845
+ writeLine(streams.stdout, chosen.length > 0
2846
+ ? `Still missing. Install with: npm install -g ${rest}`
2847
+ : `Skipped. Install them yourself with: npm install -g ${rest}`);
2848
+ }
2849
+ };
2878
2850
  const save = async () => {
2879
2851
  const config = { provider: state.provider };
2880
2852
  if (state.provider !== 'demo') {
@@ -2904,27 +2876,23 @@ export const runSetup = async (command, streams, env = {}) => {
2904
2876
  config.fallbackBaseUrl = state.fallbackBaseUrl;
2905
2877
  }
2906
2878
  }
2907
- await mkdir(path.dirname(configPath), { recursive: true });
2908
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
2879
+ await saveConfigFile(configPath, config);
2909
2880
  writeLine(streams.stdout);
2910
2881
  writeLine(streams.stdout, `Wrote ${configPath}`);
2911
2882
  // A project-local config in this directory outranks the global file for
2912
2883
  // bare runs started here — say so, and make the suggested command pick
2913
2884
  // the file that was just written.
2914
2885
  if (configPath === globalConfigPath(env)) {
2915
- for (const shadow of [DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME]) {
2916
- const shadowPath = path.join(cwd, shadow);
2917
- try {
2918
- await readFile(shadowPath, 'utf8');
2919
- writeLine(streams.stdout, `Note: ${shadowPath} exists and takes precedence over the global config for runs started in this directory.`);
2920
- writeLine(streams.stdout, 'The suggested commands below include --config so they use what you just saved.');
2921
- shadowConfigFlag = ` --config ${quoteShellArg(configPath)}`;
2922
- break;
2923
- }
2924
- catch (error) {
2925
- if (error.code !== 'ENOENT') {
2926
- throw error;
2927
- }
2886
+ const shadowPath = path.join(cwd, DEFAULT_CONFIG_FILENAME);
2887
+ try {
2888
+ await readFile(shadowPath, 'utf8');
2889
+ writeLine(streams.stdout, `Note: ${shadowPath} exists and takes precedence over the global config for runs started in this directory.`);
2890
+ writeLine(streams.stdout, 'The suggested commands below include --config so they use what you just saved.');
2891
+ shadowConfigFlag = ` --config ${quoteShellArg(configPath)}`;
2892
+ }
2893
+ catch (error) {
2894
+ if (error.code !== 'ENOENT') {
2895
+ throw error;
2928
2896
  }
2929
2897
  }
2930
2898
  }
@@ -2941,6 +2909,13 @@ export const runSetup = async (command, streams, env = {}) => {
2941
2909
  ? `Saved Slack tokens for ${connected} agent${connected === 1 ? '' : 's'} to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`
2942
2910
  : `Removed the stored Slack tokens from ${credentialsPath(env)}.`);
2943
2911
  }
2912
+ // Before the service block below, deliberately. A package installed
2913
+ // after the daemon starts is invisible to it — installing does not
2914
+ // reload a running process — so offering here is what makes the
2915
+ // LaunchAgent come up with the Slack channel and the control API
2916
+ // already present, instead of warning about them in a log nobody reads
2917
+ // until the dashboard fails.
2918
+ await offerOptionalPackages();
2944
2919
  // Last, so the daemon starts against the config and credentials that
2945
2920
  // were just written rather than the ones it would have found a moment
2946
2921
  // ago. A service failure is reported and never fails setup: the
@@ -2996,14 +2971,14 @@ export const runSetup = async (command, streams, env = {}) => {
2996
2971
  // Exported STRATUS_* variables outrank the config file, so warn when one
2997
2972
  // would make `stratus run` behave differently from what was just saved.
2998
2973
  const conflicts = [
2999
- detectEnvOverride('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER', state.provider, '--provider'),
2974
+ detectEnvOverride('STRATUS_PROVIDER', state.provider, '--provider'),
3000
2975
  ...(state.provider !== 'demo'
3001
- ? [detectEnvOverride('STRATUS_MODEL', 'STRATUSCLAW_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
2976
+ ? [detectEnvOverride('STRATUS_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
3002
2977
  : []),
3003
2978
  ...(state.provider === 'openai'
3004
- ? [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', state.baseUrl ?? DEFAULT_OPENAI_BASE_URL, '--base-url')]
3005
- : [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', '')]),
3006
- detectEnvOverride('STRATUS_SYSTEM_PROMPT', 'STRATUSCLAW_SYSTEM_PROMPT', state.systemPrompt ?? ''),
2979
+ ? [detectEnvOverride('STRATUS_BASE_URL', state.baseUrl ?? DEFAULT_OPENAI_BASE_URL, '--base-url')]
2980
+ : [detectEnvOverride('STRATUS_BASE_URL', '')]),
2981
+ detectEnvOverride('STRATUS_SYSTEM_PROMPT', state.systemPrompt ?? ''),
3007
2982
  ].filter((conflict) => conflict !== undefined);
3008
2983
  for (const conflict of conflicts) {
3009
2984
  writeLine(streams.stdout, `Note: ${conflict.envVar}=${conflict.envValue} is exported and takes precedence over the config file (run \`unset ${conflict.envVar}\` to clear it).`);
@@ -3037,7 +3012,9 @@ export const runSetup = async (command, streams, env = {}) => {
3037
3012
  }
3038
3013
  }
3039
3014
  writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag}${shadowConfigFlag} "say hello"`);
3040
- writeLine(streams.stdout, ' stratus dashboard');
3015
+ if (dashboardReady) {
3016
+ writeLine(streams.stdout, ' stratus dashboard');
3017
+ }
3041
3018
  };
3042
3019
  try {
3043
3020
  if (!interactive) {
@@ -3088,127 +3065,93 @@ export const runSetup = async (command, streams, env = {}) => {
3088
3065
  prompter.close();
3089
3066
  }
3090
3067
  };
3091
- const personaSnippet = (instructions) => {
3092
- const firstLine = instructions?.split('\n').map((line) => line.trim()).find((line) => line.length > 0);
3093
- if (!firstLine) {
3094
- return undefined;
3068
+ /**
3069
+ * The bearer token for a gateway: an explicit flag, the environment, or the
3070
+ * token file this machine's daemon wrote.
3071
+ *
3072
+ * The file is the normal case and the reason `--gateway` needs no ceremony
3073
+ * locally. It is not always right, though: a gateway reached through a tunnel
3074
+ * has its own token, which is what the flag and the variable are for.
3075
+ */
3076
+ const gatewayToken = async (env, explicit) => {
3077
+ const fromEnv = readNonEmptyString(readProcessEnv(env).STRATUS_GATEWAY_TOKEN);
3078
+ if (explicit || fromEnv) {
3079
+ return String(explicit ?? fromEnv);
3095
3080
  }
3096
- return firstLine.length > 78 ? `${firstLine.slice(0, 77)}…` : firstLine;
3097
- };
3098
- export const runAgents = async (command, streams, env = {}) => {
3099
- // Fold any legacy per-directory memory in first so the counts below
3100
- // reflect everything each agent actually remembers.
3101
- await migrateLegacyMemory(env);
3102
- const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
3103
- const processEnv = readProcessEnv(env);
3104
- // Listing must never be blocked by a broken config — it only feeds the
3105
- // default marker and the "runs on" lines.
3106
- const { config: activeConfig } = await discoverActiveConfig(env, (message) => {
3107
- writeLine(streams.stderr, `Warning: ${message}.`);
3108
- });
3109
- const envProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
3110
- ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'));
3111
- const envModel = readNonEmptyString(processEnv.STRATUS_MODEL)
3112
- ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL);
3113
- // What a run as this soul would actually use right now — the same
3114
- // precedence as resolveRuntimeConfig (env vars, soul hints, config,
3115
- // demo), so a model-only or provider-only pin still resolves honestly.
3116
- const runsOnFor = (soulProvider, soulModel) => {
3117
- const provider = envProvider ?? soulProvider ?? activeConfig.provider ?? 'demo';
3118
- if (provider === 'demo') {
3119
- return { provider };
3120
- }
3121
- const soulModelApplies = soulProvider === undefined || soulProvider === provider;
3122
- const configModelApplies = (activeConfig.provider ?? 'openai') === provider;
3123
- const model = envModel
3124
- ?? (soulModelApplies ? soulModel : undefined)
3125
- ?? (configModelApplies ? activeConfig.model : undefined)
3126
- ?? (provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
3127
- return { provider, model };
3128
- };
3129
- const defaultSoulPath = readNonEmptyString(processEnv.STRATUS_SOUL)
3130
- ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL)
3131
- ?? activeConfig.soul;
3132
- const resolvedDefaultSoul = defaultSoulPath
3133
- ? path.resolve(readWorkingDirectory(env), defaultSoulPath)
3134
- : undefined;
3135
- const listings = [];
3136
- const addSoul = async (soulPath) => {
3137
- let parsed;
3138
- try {
3139
- parsed = parseSoul(await readFile(soulPath, 'utf8'), { seed: soulPath });
3140
- }
3141
- catch (error) {
3142
- writeLine(streams.stderr, `Warning: skipping ${soulPath}: ${error instanceof Error ? error.message : String(error)}`);
3143
- return;
3144
- }
3145
- const { agent } = parsed;
3146
- const persona = personaSnippet(agent.instructions);
3147
- listings.push({
3148
- id: agent.id,
3149
- name: agent.name,
3150
- isDefault: soulPath === resolvedDefaultSoul,
3151
- builtIn: false,
3152
- soulPath,
3153
- ...(parsed.provider ? { provider: parsed.provider } : {}),
3154
- ...(parsed.model ? { model: parsed.model } : {}),
3155
- runsOn: runsOnFor(parsed.provider, parsed.model),
3156
- memories: (await memory.list(agent.id)).length,
3157
- ...(persona ? { persona } : {}),
3158
- ...(agent.avatar ? { avatar: `${agent.avatar.style} theme, hue ${agent.avatar.hue}, palette ${agent.avatar.palette.join(' ')}` } : {}),
3159
- });
3160
- };
3161
- let rosterFiles = [];
3162
3081
  try {
3163
- rosterFiles = (await readdir(agentsDirPath(env)))
3164
- .filter((file) => file.endsWith('.md'))
3165
- .sort()
3166
- .map((file) => path.join(agentsDirPath(env), file));
3082
+ return (await readFile(gatewayTokenPath(env), 'utf8')).trim();
3167
3083
  }
3168
3084
  catch (error) {
3169
3085
  if (error.code !== 'ENOENT') {
3170
3086
  throw error;
3171
3087
  }
3088
+ throw new Error(`No gateway token: ${gatewayTokenPath(env)} does not exist, and neither --token nor STRATUS_GATEWAY_TOKEN was set. `
3089
+ + 'Start the daemon with `stratus serve` (or `stratus service install`) to create one, or pass the remote gateway\'s token.');
3172
3090
  }
3173
- for (const soulPath of rosterFiles) {
3174
- await addSoul(soulPath);
3175
- }
3176
- // A default soul can live outside ~/.stratus/agents (a project soul, a
3177
- // hand-written file) — the roster would be lying without it.
3178
- if (resolvedDefaultSoul && !rosterFiles.includes(resolvedDefaultSoul)) {
3179
- await addSoul(resolvedDefaultSoul);
3180
- }
3181
- // The built-in Stratus persona serves every run that has no soul.
3182
- const builtInPersona = personaSnippet(DEFAULT_STRATUS_AGENT.instructions);
3183
- listings.push({
3184
- id: DEFAULT_STRATUS_AGENT.id,
3185
- name: DEFAULT_STRATUS_AGENT.name,
3186
- isDefault: resolvedDefaultSoul === undefined,
3187
- builtIn: true,
3188
- runsOn: runsOnFor(),
3189
- memories: (await memory.list(DEFAULT_STRATUS_AGENT.id)).length,
3190
- ...(builtInPersona ? { persona: builtInPersona } : {}),
3191
- });
3192
- listings.sort((a, b) => {
3193
- if (a.isDefault !== b.isDefault) {
3194
- return a.isDefault ? -1 : 1;
3195
- }
3196
- if (a.builtIn !== b.builtIn) {
3197
- return a.builtIn ? 1 : -1;
3198
- }
3199
- return a.name.localeCompare(b.name);
3200
- });
3091
+ };
3092
+ /** Ask a running gateway for its roster, as data. */
3093
+ const remoteAgentSummaries = async (command, env) => {
3094
+ const token = await gatewayToken(env, command.token);
3095
+ const base = String(command.gateway).replace(/\/+$/, '');
3096
+ const fetchImpl = env.fetch ?? globalThis.fetch;
3097
+ if (typeof fetchImpl !== 'function') {
3098
+ throw new Error('fetch is unavailable, so --gateway cannot reach a daemon from this runtime.');
3099
+ }
3100
+ let response;
3101
+ try {
3102
+ response = await fetchImpl(`${base}/api/v1/agents`, {
3103
+ headers: { authorization: `Bearer ${token}` },
3104
+ });
3105
+ }
3106
+ catch (error) {
3107
+ throw new Error(`Could not reach the gateway at ${base} (${error instanceof Error ? error.message : String(error)}). `
3108
+ + 'Is stratusd running, and does it have @stratusagent/control-api installed?');
3109
+ }
3110
+ if (response.status === 401 || response.status === 403) {
3111
+ throw new Error(`The gateway at ${base} rejected this token. Check --token, STRATUS_GATEWAY_TOKEN, or ~/.stratus/gateway-token.`);
3112
+ }
3113
+ if (!response.ok) {
3114
+ throw new Error(`The gateway at ${base} answered HTTP ${response.status} for the roster.`);
3115
+ }
3116
+ const payload = await response.json();
3117
+ return payload.agents ?? [];
3118
+ };
3119
+ export const runAgents = async (command, streams, env = {}) => {
3120
+ // The same listing either way — the shared builder produces it locally, and
3121
+ // the control API serves exactly what that builder produced. Rendering is
3122
+ // the only thing this command does with it.
3123
+ let listings;
3124
+ if (command.gateway) {
3125
+ listings = await remoteAgentSummaries(command, env);
3126
+ }
3127
+ else {
3128
+ // Fold any legacy per-directory memory in first so the counts below
3129
+ // reflect everything each agent actually remembers.
3130
+ await migrateLegacyMemory(env);
3131
+ listings = await listAgentSummaries(env, (message) => {
3132
+ writeLine(streams.stderr, `Warning: ${message}.`);
3133
+ });
3134
+ }
3135
+ // The palette travels structurally now, because a web or macOS surface has
3136
+ // to draw it. This output has always been one prose line, and a shape
3137
+ // change here would break every script reading it — so the rendering, not
3138
+ // the data, stays where it was.
3139
+ const describeAvatar = (avatar) => `${avatar.style} theme, hue ${avatar.hue}, palette ${avatar.palette.join(' ')}`;
3201
3140
  if (command.format === 'json') {
3202
3141
  writeLine(streams.stdout, JSON.stringify({
3203
- agents: listings.map(({ isDefault, ...rest }) => ({ ...rest, default: isDefault })),
3142
+ agents: listings.map(({ default: isDefault, avatar, ...rest }) => ({
3143
+ ...rest,
3144
+ ...(avatar ? { avatar: describeAvatar(avatar) } : {}),
3145
+ default: isDefault,
3146
+ })),
3204
3147
  }, null, 2));
3205
3148
  return 0;
3206
3149
  }
3207
3150
  const describeRunsOn = (runsOn) => (runsOn.provider === 'demo' ? 'demo (offline)' : `${runsOn.provider}${runsOn.model ? ` · ${runsOn.model}` : ''}`);
3208
- writeLine(streams.stdout, 'Agents');
3151
+ writeLine(streams.stdout, command.gateway ? `Agents on ${command.gateway}` : 'Agents');
3209
3152
  for (const agent of listings) {
3210
3153
  const labels = [
3211
- ...(agent.isDefault ? ['default'] : []),
3154
+ ...(agent.default ? ['default'] : []),
3212
3155
  ...(agent.builtIn ? ['built-in'] : []),
3213
3156
  ];
3214
3157
  writeLine(streams.stdout);
@@ -3223,7 +3166,7 @@ export const runAgents = async (command, streams, env = {}) => {
3223
3166
  writeLine(streams.stdout, ` persona ${agent.persona}`);
3224
3167
  }
3225
3168
  if (agent.avatar) {
3226
- writeLine(streams.stdout, ` avatar ${agent.avatar}`);
3169
+ writeLine(streams.stdout, ` avatar ${describeAvatar(agent.avatar)}`);
3227
3170
  }
3228
3171
  }
3229
3172
  writeLine(streams.stdout);
@@ -3245,23 +3188,20 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3245
3188
  // Config discovery, spelled out rather than delegated: doctor has to
3246
3189
  // report the files that LOST as well as the one in use, which is the
3247
3190
  // whole point when a stray project config is the answer.
3248
- // Which of the three named it, not just that one did: telling someone to
3249
- // fix STRATUS_CONFIG when STRATUSCLAW_CONFIG is what is set leaves the
3250
- // real override in place — the same mistake as provider and key
3251
- // attribution, in the one place a typo is most likely.
3191
+ // Which of the two named it, not just that one did: telling someone to
3192
+ // fix STRATUS_CONFIG when --config is what is set leaves the real
3193
+ // override in place — the same mistake as provider and key attribution,
3194
+ // in the one place a typo is most likely.
3252
3195
  const explicitSource = command.configPath !== undefined
3253
3196
  ? { name: '--config', value: command.configPath }
3254
3197
  : readNonEmptyString(processEnv.STRATUS_CONFIG)
3255
3198
  ? { name: 'STRATUS_CONFIG', value: String(processEnv.STRATUS_CONFIG) }
3256
- : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
3257
- ? { name: 'STRATUSCLAW_CONFIG', value: String(processEnv.STRATUSCLAW_CONFIG) }
3258
- : undefined;
3199
+ : undefined;
3259
3200
  const explicit = explicitSource?.value;
3260
3201
  const candidates = explicitSource
3261
3202
  ? [{ path: path.resolve(cwd, explicitSource.value), label: explicitSource.name }]
3262
3203
  : [
3263
3204
  { path: path.join(cwd, DEFAULT_CONFIG_FILENAME), label: 'project' },
3264
- { path: path.join(cwd, LEGACY_CONFIG_FILENAME), label: 'project (legacy)' },
3265
3205
  { path: globalConfigPath(env), label: 'global' },
3266
3206
  ];
3267
3207
  const present = [];
@@ -3314,16 +3254,10 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3314
3254
  }
3315
3255
  // Which variable supplied a value, not just the value: naming the wrong
3316
3256
  // one sends the reader to unset something that was never the cause,
3317
- // leaving the real override in place. The legacy STRATUSCLAW_ prefix is
3318
- // still honored by the resolver, so it has to be reportable too.
3319
- const envPick = (...names) => {
3320
- for (const name of names) {
3321
- const value = readNonEmptyString(processEnv[name]);
3322
- if (typeof value === 'string') {
3323
- return { name, value };
3324
- }
3325
- }
3326
- return undefined;
3257
+ // leaving the real override in place.
3258
+ const envPick = (name) => {
3259
+ const value = readNonEmptyString(processEnv[name]);
3260
+ return typeof value === 'string' ? { name, value } : undefined;
3327
3261
  };
3328
3262
  /**
3329
3263
  * The verdict comes from the resolver, not from a second copy of its
@@ -3332,7 +3266,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3332
3266
  * while whether a run works at all, and what it bills, is whatever
3333
3267
  * resolveRuntimeConfig actually returns or throws.
3334
3268
  */
3335
- const envSoul = envPick('STRATUS_SOUL', 'STRATUSCLAW_SOUL');
3269
+ const envSoul = envPick('STRATUS_SOUL');
3336
3270
  const soulValue = envSoul?.value ?? fileConfig.soul;
3337
3271
  const soulSource = envSoul ? envSoul.name : (winner ? winner.path : '');
3338
3272
  const soulPath = typeof soulValue === 'string' ? path.resolve(cwd, soulValue) : undefined;
@@ -3355,7 +3289,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3355
3289
  }
3356
3290
  }
3357
3291
  }
3358
- const envProviderPick = envPick('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER');
3292
+ const envProviderPick = envPick('STRATUS_PROVIDER');
3359
3293
  const providerSource = envProviderPick
3360
3294
  ? envProviderPick.name
3361
3295
  : soul?.provider
@@ -3385,7 +3319,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3385
3319
  }
3386
3320
  let model;
3387
3321
  if (resolved && resolved.provider !== 'demo') {
3388
- const envModel = envPick('STRATUS_MODEL', 'STRATUSCLAW_MODEL');
3322
+ const envModel = envPick('STRATUS_MODEL');
3389
3323
  // A soul's model belongs to the soul's provider, and the config's model
3390
3324
  // to the config's provider — either can be stranded by an override.
3391
3325
  const soulModelApplies = soul?.provider === undefined || soul.provider === resolved.provider;
@@ -3641,40 +3575,6 @@ const serviceEnvFor = (env) => ({
3641
3575
  * profile, so the unit would come up unauthenticated while setup had just
3642
3576
  * reported everything ready. Returns the variable to name, if so.
3643
3577
  */
3644
- /**
3645
- * Every runtime the daemon would resolve: the config-wide default plus one
3646
- * per roster soul, normalized the way a dispatch normalizes it. A soul that
3647
- * pins its own provider resolves to different credentials entirely, so any
3648
- * check that looks only at the default misses exactly the agent that is
3649
- * misconfigured.
3650
- */
3651
- const servedRuntimes = async (env, configPath) => {
3652
- const { applySoulPins } = await import('@stratusagent/gateway');
3653
- const { config: activeConfig, location } = await discoverActiveConfig(env, () => { });
3654
- const context = {
3655
- ...(activeConfig.provider !== undefined ? { configProvider: activeConfig.provider } : {}),
3656
- configPresent: location !== undefined,
3657
- };
3658
- const passes = [{ selection: {}, env }];
3659
- // A roster that will not load is the gateway's to refuse, with a better
3660
- // message than this preflight could give — so it checks what it can and
3661
- // leaves the failing to start().
3662
- const rosterForRuntimes = await loadRosterSouls(env, () => { }).catch(() => []);
3663
- for (const entry of rosterForRuntimes) {
3664
- const normalized = applySoulPins(entry.soul, {}, env, context);
3665
- passes.push({ selection: normalized.selection, env: normalized.env });
3666
- }
3667
- const resolved = [];
3668
- for (const pass of passes) {
3669
- // A runtime that cannot resolve is the gateway's to report, per
3670
- // dispatch and with far better context than a startup pass has.
3671
- const runtime = await resolveStateRuntimeConfig({ ...pass.selection, ...(configPath ? { configPath } : {}) }, pass.env).catch(() => undefined);
3672
- if (runtime) {
3673
- resolved.push({ runtime, env: pass.env });
3674
- }
3675
- }
3676
- return resolved;
3677
- };
3678
3578
  /**
3679
3579
  * The daemon's `approvals` block, from the config file the daemon itself
3680
3580
  * would load. Discovery goes through the shared resolver rather than
@@ -3697,26 +3597,17 @@ const servedRuntimes = async (env, configPath) => {
3697
3597
  * fleet down over a policy block that may not even be present.
3698
3598
  */
3699
3599
  const loadServeApprovals = async (env, configPath, warn) => {
3700
- try {
3701
- const location = await resolveConfigLocation(configPath ? { configPath } : {}, env);
3702
- if (!location) {
3703
- return {};
3704
- }
3705
- const approvals = (await loadConfigFile(location.path)).approvals;
3706
- if (!approvals) {
3707
- return {};
3708
- }
3709
- if (!location.trusted) {
3710
- warn(`ignoring the approvals config in ${location.path}: a project-local config cannot decide who may approve `
3711
- + 'this daemon\'s tool calls. Move it to ~/.stratus/config.json, or pass it with --config.');
3712
- return {};
3713
- }
3714
- return approvals;
3600
+ const block = await readTrustedConfigBlock('approvals', env, configPath);
3601
+ if (block.status === 'untrusted') {
3602
+ warn(`ignoring the approvals config in ${block.path}: a project-local config cannot decide who may approve `
3603
+ + 'this daemon\'s tool calls. Move it to ~/.stratus/config.json, or pass it with --config.');
3604
+ return {};
3715
3605
  }
3716
- catch (error) {
3717
- warn(`ignoring the approvals config (${error instanceof Error ? error.message : String(error)}); refusing gated calls`);
3606
+ if (block.status === 'unreadable') {
3607
+ warn(`ignoring the approvals config (${block.error instanceof Error ? block.error.message : String(block.error)}); refusing gated calls`);
3718
3608
  return {};
3719
3609
  }
3610
+ return block.status === 'present' ? block.value : {};
3720
3611
  };
3721
3612
  /**
3722
3613
  * One line naming who can actually answer, so a remote daemon does not look
@@ -3781,8 +3672,7 @@ export const runService = async (command, streams, env = {}) => {
3781
3672
  // install directory and can come up on a different roster entirely.
3782
3673
  const processEnv = readProcessEnv(env);
3783
3674
  const selectedConfig = command.configPath
3784
- ?? readNonEmptyString(processEnv.STRATUS_CONFIG)
3785
- ?? readNonEmptyString(processEnv.STRATUSCLAW_CONFIG);
3675
+ ?? readNonEmptyString(processEnv.STRATUS_CONFIG);
3786
3676
  if (command.action === 'install') {
3787
3677
  // A config the daemon cannot parse kills it during gateway.start(),
3788
3678
  // and the manager — having accepted the start — restarts it on a
@@ -3836,6 +3726,159 @@ export const runService = async (command, streams, env = {}) => {
3836
3726
  }
3837
3727
  return result.ok ? 0 : 1;
3838
3728
  };
3729
+ /**
3730
+ * What a `skill add` source string means, in order: a directory on this
3731
+ * machine, a GitHub `owner/repo` shorthand, or a URL git can clone. The
3732
+ * shorthand is the form skills.sh and its CLI print, so a skill published
3733
+ * there installs by the name its listing shows.
3734
+ */
3735
+ const resolveSkillSource = async (source, env) => {
3736
+ const localPath = path.resolve(readWorkingDirectory(env), source);
3737
+ try {
3738
+ if ((await stat(localPath)).isDirectory()) {
3739
+ return { kind: 'local', directory: localPath };
3740
+ }
3741
+ }
3742
+ catch {
3743
+ // Not a local directory; read it as a remote source.
3744
+ }
3745
+ if (/^[\w.-]+\/[\w.-]+$/.test(source)) {
3746
+ return { kind: 'git', url: `https://github.com/${source}` };
3747
+ }
3748
+ if (/^(https?|git|ssh|file):\/\//.test(source) || /^git@[\w.-]+:/.test(source)) {
3749
+ return { kind: 'git', url: source };
3750
+ }
3751
+ throw new Error(`Cannot read ${JSON.stringify(source)} as a skill source. Pass a GitHub owner/repo, a git URL, or a local path.`);
3752
+ };
3753
+ /** Shallow-clone a skills source. Git owns every transport we would otherwise re-implement. */
3754
+ const cloneSkillSource = async (url, destination) => {
3755
+ await new Promise((resolve, reject) => {
3756
+ const child = spawn('git', ['clone', '--depth', '1', '--quiet', url, destination], {
3757
+ stdio: ['ignore', 'ignore', 'pipe'],
3758
+ });
3759
+ let stderr = '';
3760
+ child.stderr?.on('data', (chunk) => {
3761
+ stderr += String(chunk);
3762
+ });
3763
+ child.on('error', (error) => {
3764
+ reject(new Error(`Could not run git to fetch ${url}: ${error.message}`));
3765
+ });
3766
+ child.on('close', (code) => {
3767
+ if (code === 0) {
3768
+ resolve();
3769
+ }
3770
+ else {
3771
+ reject(new Error(`git clone failed for ${url}: ${stderr.trim() || `exit code ${code}`}`));
3772
+ }
3773
+ });
3774
+ });
3775
+ };
3776
+ export const runSkillAdd = async (command, streams, env = {}) => {
3777
+ const resolved = await resolveSkillSource(command.source, env);
3778
+ let sourceDir;
3779
+ let cleanup;
3780
+ if (resolved.kind === 'local') {
3781
+ sourceDir = resolved.directory;
3782
+ }
3783
+ else {
3784
+ const scratch = await mkdtemp(path.join(os.tmpdir(), 'stratus-skill-add-'));
3785
+ cleanup = () => rm(scratch, { recursive: true, force: true });
3786
+ writeLine(streams.stdout, `Fetching ${resolved.url} …`);
3787
+ try {
3788
+ await cloneSkillSource(resolved.url, scratch);
3789
+ }
3790
+ catch (error) {
3791
+ await cleanup();
3792
+ throw error;
3793
+ }
3794
+ sourceDir = scratch;
3795
+ }
3796
+ try {
3797
+ const result = await installSkillsFromDirectory(env, sourceDir, {
3798
+ ...(command.skillIds ? { only: command.skillIds } : {}),
3799
+ ...(command.force ? { force: true } : {}),
3800
+ });
3801
+ for (const skill of result.installed) {
3802
+ writeLine(streams.stdout, `installed ${skill.id} — ${skill.description}`);
3803
+ }
3804
+ for (const skip of result.skipped) {
3805
+ writeLine(streams.stderr, `Warning: skipped ${skip.id}: ${skip.reason}`);
3806
+ }
3807
+ if (result.installed.length === 0) {
3808
+ writeLine(streams.stderr, result.skipped.length > 0
3809
+ ? 'Error: nothing was installed.'
3810
+ : `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
3811
+ return 1;
3812
+ }
3813
+ // Installed is not enabled: a soul opts in through its skills:
3814
+ // allowlist, and that stays true when the install came through a
3815
+ // command. --agent is the explicit way to say both at once.
3816
+ const ids = result.installed.map((skill) => skill.id);
3817
+ if (command.agentId === undefined) {
3818
+ writeLine(streams.stdout);
3819
+ writeLine(streams.stdout, 'Installed, not yet enabled. Add to an agent\'s soul frontmatter:');
3820
+ writeLine(streams.stdout, ' skills:');
3821
+ for (const id of ids) {
3822
+ writeLine(streams.stdout, ` - ${id}`);
3823
+ }
3824
+ writeLine(streams.stdout, `(or rerun with --agent <id>, or list them: stratus skills)`);
3825
+ return 0;
3826
+ }
3827
+ const roster = await loadRosterSouls(env, (line) => writeLine(streams.stderr, `Warning: ${line}`));
3828
+ const entry = roster.find((candidate) => candidate.soul.agent.id === command.agentId);
3829
+ if (!entry) {
3830
+ writeLine(streams.stderr, `Error: no agent with id ${command.agentId} in ${agentsDirPath(env)}. The skills are installed; enable them by editing a soul.`);
3831
+ return 1;
3832
+ }
3833
+ const existing = entry.soul.agent.skills ?? [];
3834
+ const additions = ids.filter((id) => !matchesSkillAllowlist(id, existing));
3835
+ if (additions.length === 0) {
3836
+ writeLine(streams.stdout, `${entry.soul.agent.name} already has all of these enabled.`);
3837
+ return 0;
3838
+ }
3839
+ // A field edit renders through formatSoul, which canonicalizes the
3840
+ // file — same trade the control API's field edits make.
3841
+ const next = {
3842
+ ...entry.soul,
3843
+ agent: { ...entry.soul.agent, skills: [...existing, ...additions] },
3844
+ };
3845
+ await writeFile(entry.path, formatSoul(next));
3846
+ writeLine(streams.stdout, `enabled for ${next.agent.name} (${entry.path}): ${additions.join(', ')}`);
3847
+ return 0;
3848
+ }
3849
+ finally {
3850
+ await cleanup?.();
3851
+ }
3852
+ };
3853
+ export const runSkills = async (streams, env = {}) => {
3854
+ const registry = new SkillRegistry();
3855
+ const skills = await loadOperatorSkills(env, registry, (line) => {
3856
+ writeLine(streams.stderr, `Warning: ${line}`);
3857
+ });
3858
+ if (skills.length === 0) {
3859
+ writeLine(streams.stdout, `No skills installed in ${skillsDirPath(env)}.`);
3860
+ writeLine(streams.stdout, 'Install some: stratus skill add <owner/repo | git url | path>');
3861
+ return 0;
3862
+ }
3863
+ // Who has each skill enabled, so the listing answers the question that
3864
+ // follows "what is installed" — read from the same roster a dispatch
3865
+ // serves. Plugin-contributed skills are the daemon's to list
3866
+ // (/catalog/tools); this command reads the operator directory.
3867
+ const roster = await loadRosterSouls(env, () => { }).catch(() => []);
3868
+ for (const skill of skills) {
3869
+ const enabledBy = roster
3870
+ .filter((entry) => {
3871
+ const allowlist = entry.soul.agent.skills;
3872
+ return allowlist !== undefined
3873
+ && allowlist.length > 0
3874
+ && matchesSkillAllowlist(skill.id, allowlist);
3875
+ })
3876
+ .map((entry) => entry.soul.agent.name);
3877
+ const suffix = enabledBy.length > 0 ? ` — enabled by ${enabledBy.join(', ')}` : ' — enabled by nobody yet';
3878
+ writeLine(streams.stdout, `${skill.id.padEnd(24)}${skill.description}${suffix}`);
3879
+ }
3880
+ return 0;
3881
+ };
3839
3882
  export const runAgentNew = async (command, streams, env = {}) => {
3840
3883
  // On a real terminal, creating an agent is the same guided experience as
3841
3884
  // setup: a headed screen, a prefilled (editable) name, a personality, and
@@ -3872,18 +3915,15 @@ export const runAgentNew = async (command, streams, env = {}) => {
3872
3915
  writeLine(streams.stdout, `Note: ${message}.`);
3873
3916
  });
3874
3917
  const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
3875
- ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
3876
3918
  ?? activeConfig.provider
3877
3919
  ?? 'demo';
3878
3920
  // The active config's model was written for the provider named in
3879
3921
  // that config; it only travels into the soul when they still match.
3880
3922
  const configModelApplies = (activeConfig.provider ?? 'openai') === soulProvider;
3881
3923
  const soulModel = readNonEmptyString(processEnv.STRATUS_MODEL)
3882
- ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
3883
3924
  ?? (configModelApplies ? activeConfig.model : undefined)
3884
3925
  ?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
3885
3926
  const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
3886
- await mkdir(agentsDirPath(env), { recursive: true });
3887
3927
  const claimed = await claimSoulFile(env, { name, instructions: persona }, (candidate) => formatSoul({ agent: candidate, ...soulPin }), (message) => writeLine(streams.stdout, message));
3888
3928
  const { agent, soulPath } = claimed;
3889
3929
  const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [
@@ -3908,9 +3948,16 @@ export const runAgentNew = async (command, streams, env = {}) => {
3908
3948
  }
3909
3949
  }
3910
3950
  if (globalConfig !== undefined) {
3911
- const config = { ...globalConfig, provider: globalConfig.provider ?? soulProvider, soul: soulPath };
3912
- await mkdir(path.dirname(globalConfigPath(env)), { recursive: true });
3913
- await writeFile(globalConfigPath(env), `${JSON.stringify(config, null, 2)}\n`);
3951
+ // Re-validated rather than cast: `readNonEmptyString` widens to
3952
+ // `string`, and the shared parser is what says which strings are
3953
+ // provider names. Every source feeding soulProvider is already one,
3954
+ // so this narrows without being able to throw.
3955
+ const config = {
3956
+ ...globalConfig,
3957
+ provider: globalConfig.provider ?? parseProviderName(soulProvider, 'provider'),
3958
+ soul: soulPath,
3959
+ };
3960
+ await saveConfigFile(globalConfigPath(env), config);
3914
3961
  madeDefault = true;
3915
3962
  if (configLocation && configLocation.path !== globalConfigPath(env)) {
3916
3963
  writeLine(streams.stdout, `Note: ${configLocation.path} takes precedence over the global config for runs started in this directory.`);
@@ -3959,31 +4006,192 @@ export const runAgentNew = async (command, streams, env = {}) => {
3959
4006
  writeLine(streams.stdout, JSON.stringify(agent, null, 2));
3960
4007
  return 0;
3961
4008
  };
3962
- export const runDashboard = async (command, streams, env = {}) => {
3963
- const handle = await startDashboardServer({
3964
- host: command.host,
3965
- ...(command.port !== undefined ? { port: command.port } : {}),
4009
+ const readGatewayInfo = async (env) => {
4010
+ try {
4011
+ const parsed = JSON.parse(await readFile(gatewayInfoPath(env), 'utf8'));
4012
+ return typeof parsed.url === 'string' ? { url: parsed.url, ...(parsed.pid ? { pid: parsed.pid } : {}) } : undefined;
4013
+ }
4014
+ catch {
4015
+ // No file, or one left behind by a daemon that died without cleaning up.
4016
+ // Either way there is nothing to talk to until the health check says so.
4017
+ return undefined;
4018
+ }
4019
+ };
4020
+ /**
4021
+ * A one-time URL for the browser.
4022
+ *
4023
+ * This is the whole reason the exchange exists: the CLI can read the token
4024
+ * file and a page cannot, so the CLI lends its authority for exactly one
4025
+ * short-lived trip.
4026
+ */
4027
+ const mintDashboardUrl = async (env, base, fetchImpl) => {
4028
+ const token = await gatewayToken(env, undefined);
4029
+ const response = await fetchImpl(`${base}/api/v1/auth/ott`, {
4030
+ method: 'POST',
4031
+ headers: { authorization: `Bearer ${token}` },
3966
4032
  });
3967
- writeLine(streams.stdout, `${DASHBOARD_TITLE} ready at ${handle.url}`);
3968
- writeLine(streams.stdout, 'Press Ctrl+C to stop.');
4033
+ if (!response.ok) {
4034
+ throw new Error(`The gateway at ${base} refused to open a dashboard session (HTTP ${response.status}).`);
4035
+ }
4036
+ const payload = await response.json();
4037
+ // The relative form joined to the base we already reached, in preference to
4038
+ // the absolute one: this command knows exactly which address answered, and
4039
+ // the daemon can only infer it from headers.
4040
+ if (payload.path) {
4041
+ return `${base.replace(/\/+$/, '')}${payload.path}`;
4042
+ }
4043
+ if (!payload.url) {
4044
+ throw new Error(`The gateway at ${base} did not return a dashboard URL.`);
4045
+ }
4046
+ return payload.url;
4047
+ };
4048
+ /** Whether something is actually answering there, as opposed to a stale file. */
4049
+ const gatewayAnswering = async (env, base, fetchImpl) => {
4050
+ try {
4051
+ const token = await gatewayToken(env, undefined);
4052
+ const response = await fetchImpl(`${base}/api/v1/health`, {
4053
+ headers: { authorization: `Bearer ${token}` },
4054
+ });
4055
+ return response.ok;
4056
+ }
4057
+ catch {
4058
+ return false;
4059
+ }
4060
+ };
4061
+ /**
4062
+ * `stratus dashboard` — open the web UI against a running daemon, starting
4063
+ * one in the foreground when there is none.
4064
+ *
4065
+ * The daemon is started by calling `runServe`, not by rebuilding its wiring:
4066
+ * channels, approvals, the credential preflight, and the log writer are all
4067
+ * decisions `serve` already makes, and a second copy of them here would drift
4068
+ * the first time either side gained a rule.
4069
+ */
4070
+ export const runDashboard = async (command, streams, env = {}) => {
4071
+ const fetchImpl = env.fetch ?? globalThis.fetch;
4072
+ if (typeof fetchImpl !== 'function') {
4073
+ writeLine(streams.stderr, 'Error: fetch is unavailable, so the dashboard cannot reach a gateway.');
4074
+ return 1;
4075
+ }
4076
+ const existing = await readGatewayInfo(env);
4077
+ let base = existing && await gatewayAnswering(env, existing.url, fetchImpl) ? existing.url : undefined;
4078
+ const ownDaemon = new AbortController();
4079
+ let serving;
4080
+ if (!base) {
4081
+ writeLine(streams.stdout, 'No daemon is running — starting one. It stops when you do.');
4082
+ writeLine(streams.stdout, 'Run `stratus service install` to keep one running instead.');
4083
+ serving = runServe({
4084
+ command: 'serve',
4085
+ events: false,
4086
+ // Explicitly on. A trusted config may set `api.enabled: false` — a
4087
+ // reasonable thing for a headless box — but this command exists to
4088
+ // open the dashboard, and honouring it here would start a daemon
4089
+ // with no API and then time out waiting for the one it promised.
4090
+ api: true,
4091
+ ...(command.port !== undefined ? { apiPort: command.port } : {}),
4092
+ // Passed whenever it was asked for, including when it is the default.
4093
+ // `--host 127.0.0.1` against a config saying `0.0.0.0` is an operator
4094
+ // narrowing the bind, and dropping it because it matched the default
4095
+ // did the reverse of what they typed.
4096
+ ...(command.host !== undefined ? { apiHost: command.host } : {}),
4097
+ },
4098
+ // The daemon's own chatter belongs on stderr here: stdout is where this
4099
+ // command says where to point a browser, and interleaving the two makes
4100
+ // the one line that matters hard to find.
4101
+ { stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
4102
+ // Attached now, not at the end. `runServe` can reject before it publishes
4103
+ // anything — an unreadable roster, a session store that will not open —
4104
+ // and a rejected promise nobody is watching for fifteen seconds is an
4105
+ // unhandled rejection, which terminates the process instead of reaching
4106
+ // the message below. Captured rather than swallowed, so the reason the
4107
+ // daemon gave is the reason this command reports.
4108
+ let daemonFailure;
4109
+ const daemonExit = serving.then(() => undefined, (error) => { daemonFailure = error; });
4110
+ // Gated on the daemon actually answering, not on a delay: it publishes
4111
+ // where it bound the moment it binds, and anything less would be a race
4112
+ // dressed up as a timeout. It also stops the moment the daemon gives up,
4113
+ // rather than waiting out a timeout for a process that is already gone.
4114
+ const startedBy = Date.now() + 15_000;
4115
+ let daemonStopped = false;
4116
+ void daemonExit.then(() => { daemonStopped = true; });
4117
+ while (!base && !daemonStopped && Date.now() < startedBy) {
4118
+ const info = await readGatewayInfo(env);
4119
+ if (info && await gatewayAnswering(env, info.url, fetchImpl)) {
4120
+ base = info.url;
4121
+ break;
4122
+ }
4123
+ await new Promise((resolve) => setTimeout(resolve, 150));
4124
+ }
4125
+ if (!base) {
4126
+ ownDaemon.abort();
4127
+ await daemonExit;
4128
+ writeLine(streams.stderr, daemonFailure
4129
+ ? `Error: the daemon could not start: ${daemonFailure instanceof Error ? daemonFailure.message : String(daemonFailure)}`
4130
+ : 'Error: the daemon did not start serving its control API. Is @stratusagent/control-api installed?');
4131
+ return 1;
4132
+ }
4133
+ }
4134
+ let url;
4135
+ try {
4136
+ url = await mintDashboardUrl(env, base, fetchImpl);
4137
+ }
4138
+ catch (error) {
4139
+ ownDaemon.abort();
4140
+ await serving?.catch(() => 0);
4141
+ writeLine(streams.stderr, `Error: ${error instanceof Error ? error.message : String(error)}`);
4142
+ return 1;
4143
+ }
4144
+ writeLine(streams.stdout, `${DASHBOARD_TITLE} ready at ${base}`);
4145
+ writeLine(streams.stdout, 'That link signs one browser in and can only be used once.');
3969
4146
  if (command.openBrowser) {
3970
4147
  try {
3971
- await (env.openExternal ?? openExternalUrl)(handle.url);
4148
+ await (env.openExternal ?? openExternalUrl)(url);
3972
4149
  writeLine(streams.stdout, 'Opened your default browser.');
3973
4150
  }
3974
4151
  catch (error) {
3975
4152
  writeLine(streams.stderr, `Warning: Could not open the browser automatically: ${error instanceof Error ? error.message : String(error)}`);
4153
+ writeLine(streams.stdout, `Open this yourself: ${url}`);
3976
4154
  }
3977
4155
  }
4156
+ else {
4157
+ writeLine(streams.stdout, `Open this to sign in: ${url}`);
4158
+ }
4159
+ if (!serving) {
4160
+ // Someone else owns the daemon; this command's job is done.
4161
+ return 0;
4162
+ }
4163
+ writeLine(streams.stdout, 'Press Ctrl+C to stop the daemon.');
3978
4164
  if (env.dashboardAutoShutdownMs !== undefined) {
3979
4165
  await new Promise((resolve) => setTimeout(resolve, env.dashboardAutoShutdownMs));
3980
- await handle.close();
3981
- return 0;
4166
+ ownDaemon.abort();
4167
+ }
4168
+ return serving;
4169
+ };
4170
+ /**
4171
+ * Resolution and loading are separate questions, and only the first one
4172
+ * means "not installed". Inspecting an import's error message cannot tell
4173
+ * them apart: a package that IS installed but is missing one of its own
4174
+ * dependencies throws ERR_MODULE_NOT_FOUND naming that dependency and the
4175
+ * importer — so a broken install would read as an absent one, silently
4176
+ * disabling a channel whose stored tokens say it should be running, or
4177
+ * leaving a daemon without the API an operator installed it to have.
4178
+ */
4179
+ const defaultPackageResolver = (specifier) => {
4180
+ try {
4181
+ import.meta.resolve(specifier);
4182
+ return true;
4183
+ }
4184
+ catch {
4185
+ return false;
3982
4186
  }
3983
- await once(process, 'SIGINT');
3984
- await handle.close();
3985
- return 0;
3986
4187
  };
4188
+ /**
4189
+ * Whether an optional package is installed. The resolver is injectable
4190
+ * because what is installed is a property of the machine: a test asserting
4191
+ * on the real one would be asserting on its own node_modules, which is why
4192
+ * every optional package is a devDependency here in the first place.
4193
+ */
4194
+ const packageInstalled = (specifier, env = {}) => (env.packageResolver ?? defaultPackageResolver)(specifier);
3987
4195
  /**
3988
4196
  * Loads the optional Slack channel package, or undefined when it is not
3989
4197
  * installed. Only that package being absent is tolerated: an adapter that
@@ -3992,23 +4200,69 @@ export const runDashboard = async (command, streams, env = {}) => {
3992
4200
  * whose stored tokens say it should be running.
3993
4201
  */
3994
4202
  const loadSlackAdapter = async () => {
3995
- // Resolution and loading are separate questions, and only the first one
3996
- // means "not installed". Inspecting the import's error message cannot
3997
- // tell them apart: a package that IS installed but is missing one of
3998
- // its own dependencies throws ERR_MODULE_NOT_FOUND naming that
3999
- // dependency and this package as the importer — a broken install would
4000
- // read as an absent one, silently disabling a channel whose tokens say
4001
- // it should be running.
4002
- try {
4003
- import.meta.resolve('@stratusagent/channel-slack');
4004
- }
4005
- catch {
4203
+ if (!packageInstalled('@stratusagent/channel-slack')) {
4006
4204
  return undefined;
4007
4205
  }
4008
4206
  // Resolvable: any failure from here is a real problem with the
4009
4207
  // installed package, and surfaces.
4010
4208
  return (await import('@stratusagent/channel-slack')).createSlackChannelAdapter;
4011
4209
  };
4210
+ /**
4211
+ * Loads the optional control-API package, or undefined when it is not
4212
+ * installed. Same two-step as the Slack adapter, and for the same reason:
4213
+ * only the package being absent means "not installed". A package that IS
4214
+ * installed but is missing one of its own dependencies throws
4215
+ * ERR_MODULE_NOT_FOUND naming that dependency, so inspecting the message
4216
+ * would read a broken install as an absent one — silently leaving a daemon
4217
+ * without the API an operator installed it to have.
4218
+ */
4219
+ const loadControlApi = async () => {
4220
+ if (!packageInstalled('@stratusagent/control-api')) {
4221
+ return undefined;
4222
+ }
4223
+ return (await import('@stratusagent/control-api')).createControlApi;
4224
+ };
4225
+ /**
4226
+ * The daemon's `api` block, from the config file the daemon itself would
4227
+ * load — and only from a trusted location.
4228
+ *
4229
+ * An auto-discovered project-local `stratus.config.json` outranks the global
4230
+ * one and can be checked into any repository. Which interface a daemon binds
4231
+ * is exactly the kind of decision a cloned repo must not get to make, so an
4232
+ * untrusted config's block is ignored loudly rather than obeyed.
4233
+ */
4234
+ const loadServeApi = async (env, configPath, warn) => {
4235
+ const block = await readTrustedConfigBlock('api', env, configPath);
4236
+ if (block.status === 'untrusted') {
4237
+ warn(`ignoring the api config in ${block.path}: a project-local config cannot decide which interface this `
4238
+ + 'daemon binds. Move it to ~/.stratus/config.json, or pass it with --config.');
4239
+ return {};
4240
+ }
4241
+ if (block.status === 'unreadable') {
4242
+ warn(`ignoring the api config (${block.error instanceof Error ? block.error.message : String(block.error)}); using the defaults`);
4243
+ return {};
4244
+ }
4245
+ return block.status === 'present' ? block.value : {};
4246
+ };
4247
+ /**
4248
+ * The daemon's `plugins` block — the same trust boundary as the two above,
4249
+ * and the one it was written for. A plugin runs in-process with the daemon,
4250
+ * so a list of them is a list of code; a `stratus.config.json` that ships
4251
+ * in a cloned repository must not be able to write it.
4252
+ */
4253
+ const loadServePlugins = async (env, configPath, warn) => {
4254
+ const block = await readTrustedConfigBlock('plugins', env, configPath);
4255
+ if (block.status === 'untrusted') {
4256
+ warn(`ignoring the plugins config in ${block.path}: a project-local config cannot decide which code runs inside `
4257
+ + 'this daemon. Move it to ~/.stratus/config.json, or pass it with --config.');
4258
+ return {};
4259
+ }
4260
+ if (block.status === 'unreadable') {
4261
+ warn(`ignoring the plugins config (${block.error instanceof Error ? block.error.message : String(block.error)}); loading no plugins`);
4262
+ return {};
4263
+ }
4264
+ return block.status === 'present' ? block.value : {};
4265
+ };
4012
4266
  /**
4013
4267
  * `stratus serve` — run the gateway (stratusd) in the foreground: load the
4014
4268
  * roster, accept dispatches, print events, and drain cleanly on SIGTERM,
@@ -4053,9 +4307,53 @@ export const runServe = async (command, streams, env = {}) => {
4053
4307
  // approve this" differently from "is anyone being asked at all".
4054
4308
  const approvalsConfig = await loadServeApprovals(env, command.configPath, warn);
4055
4309
  const approvalMode = command.approvals ?? approvalsConfig.mode ?? 'headless';
4310
+ // Read here rather than inside the gateway for the same reason as the two
4311
+ // blocks above: the trust boundary is a property of *which file* said it,
4312
+ // and this is where the file precedence is already understood.
4313
+ const pluginsConfig = await loadServePlugins(env, command.configPath, warn);
4314
+ // The control API is a channel adapter like any other: started after the
4315
+ // roster loads, stopped before the store drains. It is optional because
4316
+ // installing it is how an operator says they want a port open.
4317
+ const apiConfig = await loadServeApi(env, command.configPath, warn);
4318
+ const apiWanted = command.api ?? apiConfig.enabled ?? true;
4319
+ // Typed as the seam, not as whatever the first push happens to be: the
4320
+ // list holds the control API and the Slack adapter alike.
4321
+ const controlApiChannels = [];
4322
+ if (apiWanted) {
4323
+ const createControlApi = await loadControlApi();
4324
+ // Resolved into locals first. Inlined, `a ?? b !== undefined` parses as
4325
+ // `a ?? (b !== undefined)` — so an explicit `--api-port 0`, which is how
4326
+ // you ask for any free port, was falsy and fell through to the default.
4327
+ // Everything still bound and every test still passed, on whichever port
4328
+ // nobody happened to be using.
4329
+ const apiHost = command.apiHost ?? apiConfig.host;
4330
+ const apiPort = command.apiPort ?? apiConfig.port;
4331
+ if (createControlApi) {
4332
+ controlApiChannels.push(createControlApi({
4333
+ env,
4334
+ ...(apiHost !== undefined ? { host: apiHost } : {}),
4335
+ ...(apiPort !== undefined ? { port: apiPort } : {}),
4336
+ ...(command.configPath ? { configPath: command.configPath } : {}),
4337
+ log,
4338
+ warn,
4339
+ }));
4340
+ }
4341
+ else if (command.api === true || apiConfig.enabled === true) {
4342
+ // Only when someone asked for it explicitly. A daemon that was never
4343
+ // told to serve an API should not complain about not having one.
4344
+ warn('the control API was requested, but @stratusagent/control-api is not installed. '
4345
+ + 'Run `npm install -g @stratusagent/control-api` to bring it online; starting without it.');
4346
+ }
4347
+ }
4056
4348
  const channelCredentials = await loadChannelCredentials(env);
4057
4349
  const slackAgents = Object.entries(channelCredentials.slack ?? {});
4058
- const channels = [];
4350
+ const channels = [...controlApiChannels];
4351
+ // Tracked on its own, never as `channels.length`: the list now holds the
4352
+ // control API too, and "can anyone be asked for an approval" is a question
4353
+ // about the Slack adapter specifically. Reading it off the list length
4354
+ // would tell a daemon with an API and no Slack app that every agent is
4355
+ // reachable, and its gated calls would park with nobody rendering them.
4356
+ let slackAdapterUp = false;
4059
4357
  if (slackAgents.length > 0) {
4060
4358
  // Channel packages are optional peers: the CLI never bundles a
4061
4359
  // transport nobody asked for (the Slack SDKs alone are ~9 MB). A
@@ -4064,6 +4362,7 @@ export const runServe = async (command, streams, env = {}) => {
4064
4362
  // module-not-found stack.
4065
4363
  const adapter = await loadSlackAdapter();
4066
4364
  if (adapter) {
4365
+ slackAdapterUp = true;
4067
4366
  channels.push(adapter({
4068
4367
  agents: slackAgents.map(([agentId, tokens]) => {
4069
4368
  const route = resolveAgentApprovals(approvalsConfig, agentId);
@@ -4138,19 +4437,46 @@ export const runServe = async (command, streams, env = {}) => {
4138
4437
  // A factory, not a policy: remote mode parks turns on a transport the
4139
4438
  // gateway owns, so the policy cannot exist until the gateway is building
4140
4439
  // it. Headless takes the same path so there is one construction site.
4440
+ // The command-scope engine, which only has a caller once a shell pack is
4441
+ // installed. Wired unconditionally because it costs nothing without one:
4442
+ // a tool that carries no command string is judged by its risk exactly as
4443
+ // before. The whitelist lives beside the agent's soul, per agent.
4444
+ const commands = {
4445
+ whitelist: createFileCommandWhitelist({ directory: agentsDirPath(env) }),
4446
+ onScopeRemembered: ({ agentId, scope }) => {
4447
+ // An approval that widens what runs unattended, for every future
4448
+ // session, is precisely the decision that must not be the one leaving
4449
+ // no trace.
4450
+ log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
4451
+ },
4452
+ };
4141
4453
  const approvals = (transport) => createPermissionPolicy(approvalMode === 'remote'
4142
- ? { mode: 'remote', request: transport.request, onDecision }
4143
- : { mode: 'headless', onDecision });
4454
+ ? { mode: 'remote', request: transport.request, onDecision, commands }
4455
+ : { mode: 'headless', onDecision, commands });
4144
4456
  if (approvalMode === 'remote') {
4145
4457
  // Only agents whose channel actually came up can be asked: tokens on
4146
4458
  // disk with the Slack package missing means nothing renders the
4147
4459
  // request, and the turn discovers that by hanging.
4148
- const askable = channels.length > 0 ? slackAgents.map(([agentId]) => agentId) : [];
4460
+ const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
4149
4461
  log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
4150
4462
  }
4151
4463
  const gateway = createGateway({
4152
4464
  env,
4153
4465
  approvals,
4466
+ ...(Object.keys(pluginsConfig).length > 0
4467
+ ? {
4468
+ plugins: pluginsConfig,
4469
+ // Resolved from *here*, not from inside the gateway.
4470
+ // `import.meta.resolve` answers relative to the module that calls
4471
+ // it, and a plugin is installed alongside the thing the operator
4472
+ // installed — this CLI. Letting the gateway resolve from its own
4473
+ // location works only where the layout happens to be flat.
4474
+ pluginHost: {
4475
+ resolve: (specifier) => import.meta.resolve(specifier),
4476
+ import: (specifier) => import(__rewriteRelativeImportExtension(specifier)),
4477
+ },
4478
+ }
4479
+ : {}),
4154
4480
  ...(approvalsConfig.timeoutMs !== undefined ? { approvalTimeoutMs: approvalsConfig.timeoutMs } : {}),
4155
4481
  ...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
4156
4482
  ...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
@@ -4226,7 +4552,7 @@ export const runServe = async (command, streams, env = {}) => {
4226
4552
  // another one is about to answer — so it is reported here, where the
4227
4553
  // roster and the channel list are both in view.
4228
4554
  if (approvalMode === 'remote') {
4229
- const askable = new Set(channels.length > 0 ? slackAgents.map(([agentId]) => agentId) : []);
4555
+ const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
4230
4556
  const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
4231
4557
  if (unreachable.length > 0) {
4232
4558
  warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
@@ -4311,6 +4637,12 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
4311
4637
  if (command.command === 'agents') {
4312
4638
  return await runAgents(command, streams, resolvedEnv);
4313
4639
  }
4640
+ if (command.command === 'skill-add') {
4641
+ return await runSkillAdd(command, streams, resolvedEnv);
4642
+ }
4643
+ if (command.command === 'skills') {
4644
+ return await runSkills(streams, resolvedEnv);
4645
+ }
4314
4646
  if (command.command === 'doctor') {
4315
4647
  return await runDoctor(command, streams, resolvedEnv);
4316
4648
  }
@@ -4342,6 +4674,7 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
4342
4674
  runtime,
4343
4675
  approvals: command.approvals,
4344
4676
  ...(command.maxTurns !== undefined ? { maxTurns: command.maxTurns } : {}),
4677
+ ...(command.configPath ? { configPath: command.configPath } : {}),
4345
4678
  env: resolvedEnv,
4346
4679
  });
4347
4680
  if (command.format === 'json') {