@stratusagent/cli 0.6.0 → 0.8.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
@@ -8,16 +8,17 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { spawn } from 'node:child_process';
11
- import { readdir, readFile } from 'node:fs/promises';
11
+ import { mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
12
+ import os from 'node:os';
12
13
  import path from 'node:path';
13
14
  import { createInterface } from 'node:readline';
14
- import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@stratusagent/core';
15
+ import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, } from '@stratusagent/core';
15
16
  import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
16
17
  import { loadPlugins } from '@stratusagent/plugins';
17
18
  import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
18
19
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
19
- import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
20
- 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, loadRosterSouls, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
20
+ import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, 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, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
21
22
  import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
22
23
  export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
23
24
  import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
@@ -26,7 +27,50 @@ export { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRec
26
27
  // provider wiring now (the gateway uses the same code); the CLI re-exports
27
28
  // its historical surface so existing importers keep working.
28
29
  export { createFileMemoryStore } from '@stratusagent/state';
29
- export const CLI_VERSION = '0.6.0';
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';
30
74
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
31
75
  /**
32
76
  * The range every package declares in its `engines` field. Two floors, not
@@ -80,6 +124,11 @@ Usage:
80
124
  stratus run --config ./stratus.config.json --provider openai "Say hello"
81
125
  stratus agents
82
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
130
+ stratus schedules
131
+ stratus schedules cancel <id>
83
132
  stratus doctor
84
133
  stratus service install
85
134
  stratus service status
@@ -93,7 +142,9 @@ Commands:
93
142
  subscription or API key), create your agent, connect it to
94
143
  Slack, and test it — settings go to ~/.stratus/config.json,
95
144
  sign-ins and channel tokens to ~/.stratus/credentials.json
96
- (0600)
145
+ (0600). Save & finish offers to install any optional
146
+ package your answers imply (the Slack channel, the control
147
+ API and dashboard) before it starts the daemon
97
148
  chat Talk with your agent — the conversation persists across turns
98
149
  and remembered facts accumulate; /exit or Ctrl+C to leave
99
150
  run Execute one local Stratus Agent session
@@ -115,6 +166,21 @@ Commands:
115
166
  logs Read the daemon's log from any terminal: -f to follow,
116
167
  -n <count> for backlog, --agent / --session to filter,
117
168
  --format json for the raw records
169
+ skill add Install skills from a GitHub repo (owner/repo or URL) or a
170
+ local path into ~/.stratus/skills — whole directories, one
171
+ per skill; works with skills published for other agents
172
+ (skills.sh-style repos). Installed is not enabled: a soul
173
+ opts in via skills:, or pass --agent <id> to enable now.
174
+ --skill <id> picks from a multi-skill repo (repeatable),
175
+ --force replaces an already-installed id
176
+ skills List installed skills and which agents enable each
177
+ (also: stratus skill list)
178
+ schedules List every schedule the fleet has set — cadence, prompt,
179
+ pre-authorized destination, next firing — straight from the
180
+ daemon's database (--format json). "stratus schedules
181
+ cancel <id>" stops the next firing and revokes the
182
+ destination that was approved with it
183
+ (also: stratus schedule list / schedule cancel <id>)
118
184
  agent new Create an agent identity (generates a human-ish name + avatar theme)
119
185
  agents List your agents: who they are, where their souls live, what
120
186
  they run on, what they remember (also: stratus agent list).
@@ -163,7 +229,6 @@ Config file:
163
229
  The CLI looks for ./stratus.config.json first, then a path from --config / STRATUS_CONFIG,
164
230
  then the global ~/.stratus/config.json written by \`stratus setup\`.
165
231
  A "soul" key (or STRATUS_SOUL) points at a soul file so every run uses that agent.
166
- Legacy STRATUSCLAW_* env vars and stratusclaw.config.json are still supported for compatibility.
167
232
 
168
233
  Plugins (tools):
169
234
  Capability is optional: install a package, then list it under "plugins" in a
@@ -183,10 +248,13 @@ Plugins (tools):
183
248
  Installing one grants no agent anything — each soul lists what it may call.
184
249
 
185
250
  Soul files:
186
- A soul file is markdown with frontmatter (name, provider, model, tools, credentials)
251
+ A soul file is markdown with frontmatter (name, provider, model, tools, skills, credentials)
187
252
  followed by the agent's persona in prose. See examples/souls/ava.md.
188
253
  "tools" takes exact names or a whole toolset: tools: [fs.read, fs.search] or
189
254
  tools: [fs.*]. Omitted means every registered tool.
255
+ "skills" is the same allowlist shape over installed skills (see stratus
256
+ skills), except omitted means none — a skill is enabled per agent, never
257
+ by being installed.
190
258
  `;
191
259
  const writeLine = (stream, line = '') => {
192
260
  stream.write(`${line}\n`);
@@ -425,6 +493,48 @@ export const parseCommand = (argv, env = {}) => {
425
493
  ...(token ? { token } : {}),
426
494
  };
427
495
  }
496
+ if (command === 'schedules' || (command === 'schedule' && rest[0] === 'list') || (command === 'schedule' && rest[0] === 'cancel')) {
497
+ const schedulesRest = command === 'schedules' ? rest : rest.slice(1);
498
+ const action = (command === 'schedule' ? rest[0] : schedulesRest[0]) === 'cancel' ? 'cancel' : 'list';
499
+ const tokens = action === 'cancel' && command === 'schedules' ? schedulesRest.slice(1) : schedulesRest;
500
+ let format = 'text';
501
+ let scheduleId;
502
+ for (let index = 0; index < tokens.length; index += 1) {
503
+ const token = tokens[index];
504
+ if (!token || (action === 'list' && token === 'list' && index === 0)) {
505
+ continue;
506
+ }
507
+ if (token === '--help' || token === '-h') {
508
+ return { command: 'help' };
509
+ }
510
+ if (token === '--format') {
511
+ const value = readOptionValue(tokens, index, '--format');
512
+ if (value !== 'text' && value !== 'json') {
513
+ throw new Error(`Unsupported format: ${value}`);
514
+ }
515
+ format = value;
516
+ index += 1;
517
+ continue;
518
+ }
519
+ if (token.startsWith('--')) {
520
+ throw new Error(`Unknown option: ${token}`);
521
+ }
522
+ if (action === 'cancel' && scheduleId === undefined) {
523
+ scheduleId = token;
524
+ continue;
525
+ }
526
+ throw new Error(`Unexpected argument: ${token}. Try: stratus schedules, stratus schedules cancel <id>`);
527
+ }
528
+ if (action === 'cancel' && !scheduleId) {
529
+ throw new Error('schedules cancel needs the schedule id: stratus schedules cancel <id>.');
530
+ }
531
+ return {
532
+ command: 'schedules',
533
+ action,
534
+ ...(scheduleId ? { scheduleId } : {}),
535
+ format,
536
+ };
537
+ }
428
538
  if (command === 'doctor') {
429
539
  let format = 'text';
430
540
  let configPath;
@@ -548,6 +658,69 @@ export const parseCommand = (argv, env = {}) => {
548
658
  ...(sessionId ? { sessionId } : {}),
549
659
  };
550
660
  }
661
+ if (command === 'skills' || (command === 'skill' && rest[0] === 'list')) {
662
+ const skillsRest = command === 'skills' ? rest : rest.slice(1);
663
+ for (const token of skillsRest) {
664
+ if (token === '--help' || token === '-h') {
665
+ return { command: 'help' };
666
+ }
667
+ throw new Error(`Unknown option: ${token}`);
668
+ }
669
+ return { command: 'skills' };
670
+ }
671
+ if (command === 'skill') {
672
+ const [subcommand, ...skillRest] = rest;
673
+ if (subcommand === '--help' || subcommand === '-h') {
674
+ return { command: 'help' };
675
+ }
676
+ if (subcommand !== 'add') {
677
+ throw new Error(`Unknown skill subcommand: ${subcommand ?? '(missing)'}. Try: stratus skill add <source>, stratus skills`);
678
+ }
679
+ let source;
680
+ const skillIds = [];
681
+ let force = false;
682
+ let agentId;
683
+ for (let index = 0; index < skillRest.length; index += 1) {
684
+ const token = skillRest[index];
685
+ if (!token) {
686
+ continue;
687
+ }
688
+ if (token === '--help' || token === '-h') {
689
+ return { command: 'help' };
690
+ }
691
+ if (token === '--skill') {
692
+ skillIds.push(readOptionValue(skillRest, index, '--skill'));
693
+ index += 1;
694
+ continue;
695
+ }
696
+ if (token === '--agent') {
697
+ agentId = readOptionValue(skillRest, index, '--agent');
698
+ index += 1;
699
+ continue;
700
+ }
701
+ if (token === '--force') {
702
+ force = true;
703
+ continue;
704
+ }
705
+ if (token.startsWith('--')) {
706
+ throw new Error(`Unknown option: ${token}`);
707
+ }
708
+ if (source !== undefined) {
709
+ throw new Error(`skill add takes one source; got both ${JSON.stringify(source)} and ${JSON.stringify(token)}.`);
710
+ }
711
+ source = token;
712
+ }
713
+ if (source === undefined) {
714
+ throw new Error('skill add needs a source: a GitHub owner/repo, a git URL, or a local path.');
715
+ }
716
+ return {
717
+ command: 'skill-add',
718
+ source,
719
+ ...(skillIds.length > 0 ? { skillIds } : {}),
720
+ ...(force ? { force } : {}),
721
+ ...(agentId !== undefined ? { agentId } : {}),
722
+ };
723
+ }
551
724
  if (command === 'agent') {
552
725
  const [subcommand, ...agentRest] = rest;
553
726
  if (subcommand === '--help' || subcommand === '-h') {
@@ -772,7 +945,7 @@ export const formatEvent = (event) => {
772
945
  * granted permission leaves no trace whatsoever. Still no tool input: what
773
946
  * was asked is here, what it was asked with is not.
774
947
  */
775
- const eventDetail = (event) => {
948
+ export const eventDetail = (event) => {
776
949
  switch (event.type) {
777
950
  case 'session.updated':
778
951
  return { status: event.status };
@@ -781,8 +954,19 @@ const eventDetail = (event) => {
781
954
  case 'tool.called':
782
955
  case 'tool.denied':
783
956
  return { tool: event.call.toolName };
784
- case 'tool.completed':
785
- return { tool: event.result.toolName, ok: event.result.ok };
957
+ case 'tool.completed': {
958
+ // A memory write or retirement names the entry it touched — the id
959
+ // is a reference, not content, and "when did the agent learn/drop
960
+ // this" is unanswerable later without it. The fact itself stays out
961
+ // of the trace, like every other tool input and output.
962
+ const output = event.result.output;
963
+ const entry = (event.result.toolName === MEMORY_TOOL_NAME || event.result.toolName === FORGET_TOOL_NAME)
964
+ && event.result.ok && typeof output === 'object' && output !== null && !Array.isArray(output)
965
+ && typeof output.id === 'string'
966
+ ? output.id
967
+ : undefined;
968
+ return { tool: event.result.toolName, ok: event.result.ok, ...(entry !== undefined ? { entry } : {}) };
969
+ }
786
970
  case 'tool.approval-requested':
787
971
  return { tool: event.call.toolName, risk: event.risk, requestId: event.requestId };
788
972
  case 'tool.approval-resolved':
@@ -824,9 +1008,8 @@ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
824
1008
  return;
825
1009
  }
826
1010
  // The resolver records the variable that actually won — guessing it here
827
- // would name the wrong one whenever a custom apiKeyEnv or the legacy
828
- // STRATUSCLAW_ prefix supplied the key, sending the reader to unset
829
- // something that was never the cause.
1011
+ // would name the wrong one whenever a custom apiKeyEnv supplied the key,
1012
+ // sending the reader to unset something that was never the cause.
830
1013
  const primary = runtime.provider === 'anthropic' && runtime.apiKey ? runtime.apiKeyEnvVar : undefined;
831
1014
  // A fallback demoted the same way costs exactly as much, and only bites
832
1015
  // once the primary is already failing — the worst moment to discover it.
@@ -897,6 +1080,17 @@ const createAgentRuntime = async (streams, options) => {
897
1080
  const tools = new ToolRegistry();
898
1081
  tools.register(createDemoTool());
899
1082
  tools.register(createRememberTool(memory));
1083
+ tools.register(createRecallTool(memory));
1084
+ tools.register(createForgetTool(memory));
1085
+ // The same skills the daemon would serve, from the same directory, for
1086
+ // the same reason the plugins below match: a skill that routes in
1087
+ // `stratus run` routes in `stratus serve`, and one that is broken is
1088
+ // broken (and warned about) in both. The runner registers `skill.read`
1089
+ // itself, gated on the soul enabling any skill.
1090
+ const skills = new SkillRegistry();
1091
+ await loadOperatorSkills(runEnv, skills, (line) => {
1092
+ writeLine(streams.stderr, `Warning: ${line}`);
1093
+ });
900
1094
  const bus = new EventBus({
901
1095
  onError: (error) => {
902
1096
  writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -932,6 +1126,7 @@ const createAgentRuntime = async (streams, options) => {
932
1126
  import: (specifier) => import(__rewriteRelativeImportExtension(specifier)),
933
1127
  },
934
1128
  tools,
1129
+ skills,
935
1130
  bus,
936
1131
  workspaceRoot: workspacesDirPath(runEnv),
937
1132
  });
@@ -959,6 +1154,7 @@ const createAgentRuntime = async (streams, options) => {
959
1154
  executor: createLocalCommandExecutor(),
960
1155
  approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}, options.askApproval),
961
1156
  bus,
1157
+ skills,
962
1158
  memory,
963
1159
  ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
964
1160
  });
@@ -967,6 +1163,13 @@ const createAgentRuntime = async (streams, options) => {
967
1163
  // A soul is a full identity — without one, every provider serves the
968
1164
  // same built-in Stratus persona.
969
1165
  const agent = options.runtime.soul?.agent ?? DEFAULT_STRATUS_AGENT;
1166
+ // The same advisory the daemon gives at roster load, from the same
1167
+ // kernel check: a soul enabling a skill whose `requires:` its `tools:`
1168
+ // does not cover must warn here too, or the local test stays silent
1169
+ // about a configuration `stratus serve` flags.
1170
+ for (const { skill, missing } of missingSkillRequirements(agent, skills)) {
1171
+ writeLine(streams.stderr, `Warning: agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
1172
+ }
970
1173
  const metadata = options.runtime.provider === 'demo'
971
1174
  ? { provider: 'demo', executor: 'local-command' }
972
1175
  : {
@@ -1629,11 +1832,17 @@ const verifySlackAppToken = async (token, fetchImpl) => {
1629
1832
  // package being installed.
1630
1833
  const SLACK_BOT_SCOPES = [
1631
1834
  'app_mentions:read',
1835
+ // The conversations read family backs outbound destination validation
1836
+ // (conversations.info): whether a channel a schedule wants to report to
1837
+ // exists, and whether this app is a member of it.
1838
+ 'channels:read',
1632
1839
  'chat:write',
1633
1840
  'files:write',
1841
+ 'groups:read',
1634
1842
  'im:history',
1635
1843
  'im:read',
1636
1844
  'im:write',
1845
+ 'mpim:read',
1637
1846
  'users:read',
1638
1847
  ];
1639
1848
  const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
@@ -1663,20 +1872,17 @@ export const slackAppManifest = (agentName) => JSON.stringify({
1663
1872
  const DEFAULT_SOUL_STARTER = [
1664
1873
  'You are a helpful, warm generalist. Answer first, explain second, and',
1665
1874
  'keep replies short unless the question genuinely needs depth. Use',
1666
- 'memory.remember for durable facts about the people you work with.',
1875
+ 'memory.remember for durable facts about the people you work with, and',
1876
+ 'memory.recall to look up what you know when a conversation calls for it.',
1667
1877
  ].join('\n');
1668
1878
  export const runSetup = async (command, streams, env = {}) => {
1669
1879
  const cwd = readWorkingDirectory(env);
1670
1880
  const processEnv = readProcessEnv(env);
1671
- // Config target: --config, then STRATUS_CONFIG / STRATUSCLAW_CONFIG, then
1672
- // the global ~/.stratus/config.json — the file `stratus run` falls back to
1673
- // from any directory, which is what makes setup a one-time step.
1674
- const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG)
1675
- ? 'STRATUS_CONFIG'
1676
- : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
1677
- ? 'STRATUSCLAW_CONFIG'
1678
- : undefined;
1679
- const envConfigPath = envConfigVar ? String(processEnv[envConfigVar]).trim() : undefined;
1881
+ // Config target: --config, then STRATUS_CONFIG, then the global
1882
+ // ~/.stratus/config.json — the file `stratus run` falls back to from any
1883
+ // directory, which is what makes setup a one-time step.
1884
+ const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG) ? 'STRATUS_CONFIG' : undefined;
1885
+ const envConfigPath = envConfigVar ? String(processEnv.STRATUS_CONFIG).trim() : undefined;
1680
1886
  const configPath = command.configPath
1681
1887
  ? path.resolve(cwd, command.configPath)
1682
1888
  : envConfigPath
@@ -1989,7 +2195,6 @@ export const runSetup = async (command, streams, env = {}) => {
1989
2195
  }
1990
2196
  const keyEnvSelector = provider === state.provider
1991
2197
  ? readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
1992
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
1993
2198
  ?? state.apiKeyEnv
1994
2199
  : undefined;
1995
2200
  return state.credentials[provider] !== undefined
@@ -2246,11 +2451,9 @@ export const runSetup = async (command, streams, env = {}) => {
2246
2451
  // stored key's bound endpoint is authoritative — so the inline test
2247
2452
  // exercises precisely what a real run will use.
2248
2453
  const keyEnv = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
2249
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
2250
2454
  ?? state.apiKeyEnv
2251
2455
  ?? defaultKeyEnvFor(state.provider);
2252
2456
  const envKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
2253
- ?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
2254
2457
  ?? readNonEmptyString(processEnv[String(keyEnv)]);
2255
2458
  const credential = envKey ? undefined : state.credentials[state.provider];
2256
2459
  const boundUrl = credential?.type === 'api_key' ? credential.baseUrl : undefined;
@@ -2409,8 +2612,7 @@ export const runSetup = async (command, streams, env = {}) => {
2409
2612
  // Listing the config soul while `stratus serve` registers the env one
2410
2613
  // would store tokens against an id the adapter then skips.
2411
2614
  const processEnv = readProcessEnv(env);
2412
- const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL)
2413
- ?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL);
2615
+ const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL);
2414
2616
  if (typeof envSoul === 'string' && state.soulPath && envSoul !== state.soulPath) {
2415
2617
  warnOnce(`STRATUS_SOUL points at ${envSoul}, which outranks the configured ${state.soulPath} — Channels lists what a run would actually use`);
2416
2618
  }
@@ -2595,25 +2797,126 @@ export const runSetup = async (command, streams, env = {}) => {
2595
2797
  }
2596
2798
  }
2597
2799
  };
2598
- const detectEnvOverride = (primary, legacy, chosen, flagName) => {
2599
- const envVar = readNonEmptyString(processEnv[primary])
2600
- ? primary
2601
- : readNonEmptyString(processEnv[legacy])
2602
- ? legacy
2603
- : undefined;
2604
- if (!envVar) {
2800
+ const detectEnvOverride = (primary, chosen, flagName) => {
2801
+ if (!readNonEmptyString(processEnv[primary])) {
2605
2802
  return undefined;
2606
2803
  }
2607
- const envValue = String(processEnv[envVar]).trim();
2804
+ const envValue = String(processEnv[primary]).trim();
2608
2805
  if (envValue === chosen) {
2609
2806
  return undefined;
2610
2807
  }
2611
2808
  return {
2612
- envVar,
2809
+ envVar: primary,
2613
2810
  envValue,
2614
2811
  ...(flagName ? { flag: `${flagName} ${quoteShellArg(chosen)}` } : {}),
2615
2812
  };
2616
2813
  };
2814
+ /**
2815
+ * Whether `stratus dashboard` can work. Read before the offer and set by
2816
+ * it, so the closing suggestions never name a command this machine cannot
2817
+ * run — the failure that sends someone to the logs to find out why.
2818
+ */
2819
+ let dashboardReady = packageInstalled('@stratusagent/control-api', env)
2820
+ && packageInstalled('@stratusagent/dashboard', env);
2821
+ const missingPackageGroups = () => {
2822
+ const groups = [];
2823
+ const slackAgents = Object.keys(state.channels.slack ?? {}).length;
2824
+ if (slackAgents > 0 && !packageInstalled('@stratusagent/channel-slack', env)) {
2825
+ groups.push({
2826
+ id: 'slack',
2827
+ label: 'Slack channel',
2828
+ why: `Slack tokens are stored for ${slackAgents} agent(s), but nothing connects to Slack without it`,
2829
+ packages: ['@stratusagent/channel-slack'],
2830
+ });
2831
+ }
2832
+ // Both, because the dashboard is what was offered: the control API is
2833
+ // the port and the dashboard is the page served on it. Only the missing
2834
+ // half is installed, so accepting this on a machine that already has
2835
+ // the API does not reinstall it.
2836
+ const dashboardPackages = ['@stratusagent/control-api', '@stratusagent/dashboard']
2837
+ .filter((name) => !packageInstalled(name, env));
2838
+ if (dashboardPackages.length > 0) {
2839
+ groups.push({
2840
+ id: 'dashboard',
2841
+ label: 'Web dashboard',
2842
+ why: '`stratus dashboard` needs it, and it opens an authenticated port on 127.0.0.1',
2843
+ packages: dashboardPackages,
2844
+ });
2845
+ }
2846
+ return groups;
2847
+ };
2848
+ /**
2849
+ * Offers the packages above. Declining is a real answer and prints the
2850
+ * command; the control API in particular binds a port, and installing it
2851
+ * is how an operator says they want one open, so this asks rather than
2852
+ * deciding for them.
2853
+ */
2854
+ const offerOptionalPackages = async () => {
2855
+ const groups = missingPackageGroups();
2856
+ if (groups.length === 0) {
2857
+ return;
2858
+ }
2859
+ writeLine(streams.stdout);
2860
+ writeLine(streams.stdout, groups.length === 1
2861
+ ? 'One optional package is not installed:'
2862
+ : `${groups.length} optional packages are not installed:`);
2863
+ for (const group of groups) {
2864
+ writeLine(streams.stdout, ` ${group.packages.join(' ')}`);
2865
+ writeLine(streams.stdout, ` ${group.why}.`);
2866
+ }
2867
+ const answer = await prompter.select('Install now with npm install -g?', groups.length === 1
2868
+ ? ['Install it now', 'Skip']
2869
+ : ['Install all of them now', ...groups.map((group) => `Install the ${group.label} only`), 'Skip']);
2870
+ const chosen = (() => {
2871
+ if (answer.kind !== 'index') {
2872
+ return [];
2873
+ }
2874
+ if (groups.length === 1) {
2875
+ return answer.index === 0 ? groups : [];
2876
+ }
2877
+ if (answer.index === 0) {
2878
+ return groups;
2879
+ }
2880
+ const only = groups[answer.index - 1];
2881
+ return only ? [only] : [];
2882
+ })();
2883
+ // Held before the install rather than derived after it: picking one
2884
+ // group is not a decision about the other, and the daemon starts a few
2885
+ // lines below and warns about exactly what is still missing — the
2886
+ // notice this whole offer exists to pre-empt.
2887
+ const declined = groups.filter((group) => !chosen.includes(group));
2888
+ if (chosen.length > 0) {
2889
+ const packages = chosen.flatMap((group) => group.packages);
2890
+ writeLine(streams.stdout, `Running: npm install -g ${packages.join(' ')}`);
2891
+ // Never fails setup, for the same reason the service install does not:
2892
+ // the config and credentials are already written, and a package that
2893
+ // did not install is a warning at the next start, not a broken machine.
2894
+ const result = await (env.packageInstaller ?? defaultPackageInstaller)(packages)
2895
+ .catch((error) => ({
2896
+ ok: false,
2897
+ message: error instanceof Error ? error.message : String(error),
2898
+ }));
2899
+ if (result.ok) {
2900
+ writeLine(streams.stdout, `Installed ${packages.join(' ')}.`);
2901
+ // npm's exit code, not a second resolve: a package written into the
2902
+ // global prefix a moment ago need not be resolvable from THIS
2903
+ // process, whose module resolution was fixed when it started.
2904
+ if (chosen.some((group) => group.id === 'dashboard')) {
2905
+ dashboardReady = true;
2906
+ }
2907
+ }
2908
+ else {
2909
+ writeLine(streams.stderr, `Could not install: ${result.message}`);
2910
+ writeLine(streams.stderr, `Setup is saved either way — run \`npm install -g ${packages.join(' ')}\` yourself.`);
2911
+ }
2912
+ }
2913
+ if (declined.length > 0) {
2914
+ const rest = declined.flatMap((group) => group.packages).join(' ');
2915
+ writeLine(streams.stdout, chosen.length > 0
2916
+ ? `Still missing. Install with: npm install -g ${rest}`
2917
+ : `Skipped. Install them yourself with: npm install -g ${rest}`);
2918
+ }
2919
+ };
2617
2920
  const save = async () => {
2618
2921
  const config = { provider: state.provider };
2619
2922
  if (state.provider !== 'demo') {
@@ -2650,19 +2953,16 @@ export const runSetup = async (command, streams, env = {}) => {
2650
2953
  // bare runs started here — say so, and make the suggested command pick
2651
2954
  // the file that was just written.
2652
2955
  if (configPath === globalConfigPath(env)) {
2653
- for (const shadow of [DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME]) {
2654
- const shadowPath = path.join(cwd, shadow);
2655
- try {
2656
- await readFile(shadowPath, 'utf8');
2657
- writeLine(streams.stdout, `Note: ${shadowPath} exists and takes precedence over the global config for runs started in this directory.`);
2658
- writeLine(streams.stdout, 'The suggested commands below include --config so they use what you just saved.');
2659
- shadowConfigFlag = ` --config ${quoteShellArg(configPath)}`;
2660
- break;
2661
- }
2662
- catch (error) {
2663
- if (error.code !== 'ENOENT') {
2664
- throw error;
2665
- }
2956
+ const shadowPath = path.join(cwd, DEFAULT_CONFIG_FILENAME);
2957
+ try {
2958
+ await readFile(shadowPath, 'utf8');
2959
+ writeLine(streams.stdout, `Note: ${shadowPath} exists and takes precedence over the global config for runs started in this directory.`);
2960
+ writeLine(streams.stdout, 'The suggested commands below include --config so they use what you just saved.');
2961
+ shadowConfigFlag = ` --config ${quoteShellArg(configPath)}`;
2962
+ }
2963
+ catch (error) {
2964
+ if (error.code !== 'ENOENT') {
2965
+ throw error;
2666
2966
  }
2667
2967
  }
2668
2968
  }
@@ -2679,6 +2979,13 @@ export const runSetup = async (command, streams, env = {}) => {
2679
2979
  ? `Saved Slack tokens for ${connected} agent${connected === 1 ? '' : 's'} to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`
2680
2980
  : `Removed the stored Slack tokens from ${credentialsPath(env)}.`);
2681
2981
  }
2982
+ // Before the service block below, deliberately. A package installed
2983
+ // after the daemon starts is invisible to it — installing does not
2984
+ // reload a running process — so offering here is what makes the
2985
+ // LaunchAgent come up with the Slack channel and the control API
2986
+ // already present, instead of warning about them in a log nobody reads
2987
+ // until the dashboard fails.
2988
+ await offerOptionalPackages();
2682
2989
  // Last, so the daemon starts against the config and credentials that
2683
2990
  // were just written rather than the ones it would have found a moment
2684
2991
  // ago. A service failure is reported and never fails setup: the
@@ -2734,14 +3041,14 @@ export const runSetup = async (command, streams, env = {}) => {
2734
3041
  // Exported STRATUS_* variables outrank the config file, so warn when one
2735
3042
  // would make `stratus run` behave differently from what was just saved.
2736
3043
  const conflicts = [
2737
- detectEnvOverride('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER', state.provider, '--provider'),
3044
+ detectEnvOverride('STRATUS_PROVIDER', state.provider, '--provider'),
2738
3045
  ...(state.provider !== 'demo'
2739
- ? [detectEnvOverride('STRATUS_MODEL', 'STRATUSCLAW_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
3046
+ ? [detectEnvOverride('STRATUS_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
2740
3047
  : []),
2741
3048
  ...(state.provider === 'openai'
2742
- ? [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', state.baseUrl ?? DEFAULT_OPENAI_BASE_URL, '--base-url')]
2743
- : [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', '')]),
2744
- detectEnvOverride('STRATUS_SYSTEM_PROMPT', 'STRATUSCLAW_SYSTEM_PROMPT', state.systemPrompt ?? ''),
3049
+ ? [detectEnvOverride('STRATUS_BASE_URL', state.baseUrl ?? DEFAULT_OPENAI_BASE_URL, '--base-url')]
3050
+ : [detectEnvOverride('STRATUS_BASE_URL', '')]),
3051
+ detectEnvOverride('STRATUS_SYSTEM_PROMPT', state.systemPrompt ?? ''),
2745
3052
  ].filter((conflict) => conflict !== undefined);
2746
3053
  for (const conflict of conflicts) {
2747
3054
  writeLine(streams.stdout, `Note: ${conflict.envVar}=${conflict.envValue} is exported and takes precedence over the config file (run \`unset ${conflict.envVar}\` to clear it).`);
@@ -2775,7 +3082,9 @@ export const runSetup = async (command, streams, env = {}) => {
2775
3082
  }
2776
3083
  }
2777
3084
  writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag}${shadowConfigFlag} "say hello"`);
2778
- writeLine(streams.stdout, ' stratus dashboard');
3085
+ if (dashboardReady) {
3086
+ writeLine(streams.stdout, ' stratus dashboard');
3087
+ }
2779
3088
  };
2780
3089
  try {
2781
3090
  if (!interactive) {
@@ -2949,23 +3258,20 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
2949
3258
  // Config discovery, spelled out rather than delegated: doctor has to
2950
3259
  // report the files that LOST as well as the one in use, which is the
2951
3260
  // whole point when a stray project config is the answer.
2952
- // Which of the three named it, not just that one did: telling someone to
2953
- // fix STRATUS_CONFIG when STRATUSCLAW_CONFIG is what is set leaves the
2954
- // real override in place — the same mistake as provider and key
2955
- // attribution, in the one place a typo is most likely.
3261
+ // Which of the two named it, not just that one did: telling someone to
3262
+ // fix STRATUS_CONFIG when --config is what is set leaves the real
3263
+ // override in place — the same mistake as provider and key attribution,
3264
+ // in the one place a typo is most likely.
2956
3265
  const explicitSource = command.configPath !== undefined
2957
3266
  ? { name: '--config', value: command.configPath }
2958
3267
  : readNonEmptyString(processEnv.STRATUS_CONFIG)
2959
3268
  ? { name: 'STRATUS_CONFIG', value: String(processEnv.STRATUS_CONFIG) }
2960
- : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
2961
- ? { name: 'STRATUSCLAW_CONFIG', value: String(processEnv.STRATUSCLAW_CONFIG) }
2962
- : undefined;
3269
+ : undefined;
2963
3270
  const explicit = explicitSource?.value;
2964
3271
  const candidates = explicitSource
2965
3272
  ? [{ path: path.resolve(cwd, explicitSource.value), label: explicitSource.name }]
2966
3273
  : [
2967
3274
  { path: path.join(cwd, DEFAULT_CONFIG_FILENAME), label: 'project' },
2968
- { path: path.join(cwd, LEGACY_CONFIG_FILENAME), label: 'project (legacy)' },
2969
3275
  { path: globalConfigPath(env), label: 'global' },
2970
3276
  ];
2971
3277
  const present = [];
@@ -3018,16 +3324,10 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3018
3324
  }
3019
3325
  // Which variable supplied a value, not just the value: naming the wrong
3020
3326
  // one sends the reader to unset something that was never the cause,
3021
- // leaving the real override in place. The legacy STRATUSCLAW_ prefix is
3022
- // still honored by the resolver, so it has to be reportable too.
3023
- const envPick = (...names) => {
3024
- for (const name of names) {
3025
- const value = readNonEmptyString(processEnv[name]);
3026
- if (typeof value === 'string') {
3027
- return { name, value };
3028
- }
3029
- }
3030
- return undefined;
3327
+ // leaving the real override in place.
3328
+ const envPick = (name) => {
3329
+ const value = readNonEmptyString(processEnv[name]);
3330
+ return typeof value === 'string' ? { name, value } : undefined;
3031
3331
  };
3032
3332
  /**
3033
3333
  * The verdict comes from the resolver, not from a second copy of its
@@ -3036,7 +3336,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3036
3336
  * while whether a run works at all, and what it bills, is whatever
3037
3337
  * resolveRuntimeConfig actually returns or throws.
3038
3338
  */
3039
- const envSoul = envPick('STRATUS_SOUL', 'STRATUSCLAW_SOUL');
3339
+ const envSoul = envPick('STRATUS_SOUL');
3040
3340
  const soulValue = envSoul?.value ?? fileConfig.soul;
3041
3341
  const soulSource = envSoul ? envSoul.name : (winner ? winner.path : '');
3042
3342
  const soulPath = typeof soulValue === 'string' ? path.resolve(cwd, soulValue) : undefined;
@@ -3059,7 +3359,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3059
3359
  }
3060
3360
  }
3061
3361
  }
3062
- const envProviderPick = envPick('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER');
3362
+ const envProviderPick = envPick('STRATUS_PROVIDER');
3063
3363
  const providerSource = envProviderPick
3064
3364
  ? envProviderPick.name
3065
3365
  : soul?.provider
@@ -3089,7 +3389,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3089
3389
  }
3090
3390
  let model;
3091
3391
  if (resolved && resolved.provider !== 'demo') {
3092
- const envModel = envPick('STRATUS_MODEL', 'STRATUSCLAW_MODEL');
3392
+ const envModel = envPick('STRATUS_MODEL');
3093
3393
  // A soul's model belongs to the soul's provider, and the config's model
3094
3394
  // to the config's provider — either can be stranded by an override.
3095
3395
  const soulModelApplies = soul?.provider === undefined || soul.provider === resolved.provider;
@@ -3442,8 +3742,7 @@ export const runService = async (command, streams, env = {}) => {
3442
3742
  // install directory and can come up on a different roster entirely.
3443
3743
  const processEnv = readProcessEnv(env);
3444
3744
  const selectedConfig = command.configPath
3445
- ?? readNonEmptyString(processEnv.STRATUS_CONFIG)
3446
- ?? readNonEmptyString(processEnv.STRATUSCLAW_CONFIG);
3745
+ ?? readNonEmptyString(processEnv.STRATUS_CONFIG);
3447
3746
  if (command.action === 'install') {
3448
3747
  // A config the daemon cannot parse kills it during gateway.start(),
3449
3748
  // and the manager — having accepted the start — restarts it on a
@@ -3497,6 +3796,298 @@ export const runService = async (command, streams, env = {}) => {
3497
3796
  }
3498
3797
  return result.ok ? 0 : 1;
3499
3798
  };
3799
+ /**
3800
+ * What a `skill add` source string means, in order: a directory on this
3801
+ * machine, a GitHub `owner/repo` shorthand, or a URL git can clone. The
3802
+ * shorthand is the form skills.sh and its CLI print, so a skill published
3803
+ * there installs by the name its listing shows.
3804
+ */
3805
+ const resolveSkillSource = async (source, env) => {
3806
+ const localPath = path.resolve(readWorkingDirectory(env), source);
3807
+ try {
3808
+ if ((await stat(localPath)).isDirectory()) {
3809
+ // The real directory, not the path as typed: a source that is
3810
+ // itself a symlink would otherwise be copied as that link — an
3811
+ // installed entry pointing back at the source tree, which the
3812
+ // loader ignores as not-a-directory.
3813
+ return { kind: 'local', directory: await realpath(localPath) };
3814
+ }
3815
+ }
3816
+ catch {
3817
+ // Not a local directory; read it as a remote source.
3818
+ }
3819
+ if (/^[\w.-]+\/[\w.-]+$/.test(source)) {
3820
+ return { kind: 'git', url: `https://github.com/${source}` };
3821
+ }
3822
+ if (/^(https?|git|ssh|file):\/\//.test(source) || /^git@[\w.-]+:/.test(source)) {
3823
+ return { kind: 'git', url: source };
3824
+ }
3825
+ throw new Error(`Cannot read ${JSON.stringify(source)} as a skill source. Pass a GitHub owner/repo, a git URL, or a local path.`);
3826
+ };
3827
+ /**
3828
+ * A source URL safe to print: userinfo stripped. A token travels in exactly
3829
+ * this position (`https://user:token@host/repo.git`), stdout is commonly
3830
+ * retained in CI logs, and git's own stderr already redacts — only the
3831
+ * unredacted URL is handed to git itself.
3832
+ */
3833
+ const redactedSourceUrl = (url) => {
3834
+ try {
3835
+ const parsed = new URL(url);
3836
+ if (parsed.username || parsed.password) {
3837
+ parsed.username = '***';
3838
+ parsed.password = '';
3839
+ }
3840
+ return parsed.toString();
3841
+ }
3842
+ catch {
3843
+ // scp-style (git@host:path) or anything else URL() refuses: keep the
3844
+ // shape, hide whatever sits before the @.
3845
+ return url.replace(/^[^@/]+@/, '***@');
3846
+ }
3847
+ };
3848
+ /** Shallow-clone a skills source. Git owns every transport we would otherwise re-implement. */
3849
+ const cloneSkillSource = async (url, destination) => {
3850
+ const display = redactedSourceUrl(url);
3851
+ await new Promise((resolve, reject) => {
3852
+ const child = spawn('git', ['clone', '--depth', '1', '--quiet', url, destination], {
3853
+ stdio: ['ignore', 'ignore', 'pipe'],
3854
+ });
3855
+ let stderr = '';
3856
+ child.stderr?.on('data', (chunk) => {
3857
+ stderr += String(chunk);
3858
+ });
3859
+ child.on('error', (error) => {
3860
+ reject(new Error(`Could not run git to fetch ${display}: ${error.message}`));
3861
+ });
3862
+ child.on('close', (code) => {
3863
+ if (code === 0) {
3864
+ resolve();
3865
+ }
3866
+ else {
3867
+ // git redacts credentials in its own messages; the URL we echo is
3868
+ // ours to redact.
3869
+ reject(new Error(`git clone failed for ${display}: ${stderr.trim() || `exit code ${code}`}`));
3870
+ }
3871
+ });
3872
+ });
3873
+ };
3874
+ /**
3875
+ * The souls skill enablement is judged against: the agents directory plus
3876
+ * the configured default soul (config `soul:` / STRATUS_SOUL), which may
3877
+ * live outside it and is served all the same. The daemon's roster and
3878
+ * `stratus agents` both include it, so `skill add --agent` and `stratus
3879
+ * skills` must not answer from a narrower set.
3880
+ */
3881
+ const rosterSoulsWithConfigured = async (env, warn) => {
3882
+ const entries = await loadRosterSouls(env, warn);
3883
+ let complete = true;
3884
+ try {
3885
+ const configured = await resolveConfiguredSoul({}, env);
3886
+ if (configured) {
3887
+ const entry = { soul: configured.soul, path: configured.path };
3888
+ const clash = entries.findIndex((candidate) => candidate.soul.agent.id === configured.soul.agent.id);
3889
+ // Replaced, not skipped: a roster file claiming the configured
3890
+ // soul's id is the one the gateway stops serving — both commands
3891
+ // must answer for the soul a dispatch actually runs.
3892
+ if (clash >= 0) {
3893
+ entries[clash] = entry;
3894
+ }
3895
+ else {
3896
+ entries.push(entry);
3897
+ }
3898
+ }
3899
+ }
3900
+ catch (error) {
3901
+ // The configured agent's allowlist was not read, so the set is not
3902
+ // the roster — callers making enablement claims must withhold them.
3903
+ complete = false;
3904
+ warn(`could not read the configured soul: ${error instanceof Error ? error.message : String(error)}`);
3905
+ }
3906
+ return { entries, complete };
3907
+ };
3908
+ export const runSkillAdd = async (command, streams, env = {}) => {
3909
+ const resolved = await resolveSkillSource(command.source, env);
3910
+ let sourceDir;
3911
+ let cleanup;
3912
+ if (resolved.kind === 'local') {
3913
+ sourceDir = resolved.directory;
3914
+ }
3915
+ else {
3916
+ const scratch = await mkdtemp(path.join(os.tmpdir(), 'stratus-skill-add-'));
3917
+ cleanup = () => rm(scratch, { recursive: true, force: true });
3918
+ writeLine(streams.stdout, `Fetching ${redactedSourceUrl(resolved.url)} …`);
3919
+ try {
3920
+ await cloneSkillSource(resolved.url, scratch);
3921
+ }
3922
+ catch (error) {
3923
+ await cleanup();
3924
+ throw error;
3925
+ }
3926
+ sourceDir = scratch;
3927
+ }
3928
+ try {
3929
+ // For a cloned source the temp directory's random basename must not
3930
+ // name a root-level skill — the repository's own name is what a
3931
+ // nameless root SKILL.md installs under.
3932
+ const rootId = resolved.kind === 'git'
3933
+ ? path.basename(new URL(resolved.url.replace(/^git@([^:]+):/, 'ssh://git@$1/')).pathname, '.git')
3934
+ : undefined;
3935
+ const result = await installSkillsFromDirectory(env, sourceDir, {
3936
+ ...(command.skillIds ? { only: command.skillIds } : {}),
3937
+ ...(command.force ? { force: true } : {}),
3938
+ ...(rootId !== undefined && rootId.length > 0 ? { rootId } : {}),
3939
+ });
3940
+ for (const skill of result.installed) {
3941
+ writeLine(streams.stdout, `installed ${skill.id} — ${skill.description}`);
3942
+ }
3943
+ // Present already is a no-op, not a failure — and exactly what the
3944
+ // "rerun with --agent" hint below produces, so these stay eligible
3945
+ // for the enablement step.
3946
+ for (const skill of result.alreadyInstalled) {
3947
+ writeLine(streams.stdout, `already installed ${skill.id} — ${skill.description}`);
3948
+ }
3949
+ for (const skip of result.skipped) {
3950
+ writeLine(streams.stderr, `Warning: skipped ${skip.id}: ${skip.reason}`);
3951
+ }
3952
+ if (result.installed.length === 0 && result.alreadyInstalled.length === 0) {
3953
+ writeLine(streams.stderr, result.skipped.length > 0
3954
+ ? 'Error: nothing was installed.'
3955
+ : `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
3956
+ return 1;
3957
+ }
3958
+ // Installed is not enabled: a soul opts in through its skills:
3959
+ // allowlist, and that stays true when the install came through a
3960
+ // command. --agent is the explicit way to say both at once.
3961
+ const ids = [...result.installed, ...result.alreadyInstalled].map((skill) => skill.id);
3962
+ if (command.agentId === undefined) {
3963
+ writeLine(streams.stdout);
3964
+ writeLine(streams.stdout, 'Installed, not yet enabled. Add to an agent\'s soul frontmatter:');
3965
+ writeLine(streams.stdout, ' skills:');
3966
+ for (const id of ids) {
3967
+ writeLine(streams.stdout, ` - ${id}`);
3968
+ }
3969
+ writeLine(streams.stdout, `(or rerun with --agent <id>, or list them: stratus skills)`);
3970
+ return 0;
3971
+ }
3972
+ const { entries: roster } = await rosterSoulsWithConfigured(env, (line) => writeLine(streams.stderr, `Warning: ${line}`));
3973
+ const entry = roster.find((candidate) => candidate.soul.agent.id === command.agentId);
3974
+ if (!entry) {
3975
+ writeLine(streams.stderr, `Error: no agent with id ${command.agentId} in ${agentsDirPath(env)} or the configured soul. The skills are installed; enable them by editing a soul.`);
3976
+ return 1;
3977
+ }
3978
+ const existing = entry.soul.agent.skills ?? [];
3979
+ const additions = ids.filter((id) => !matchesSkillAllowlist(id, existing));
3980
+ if (additions.length === 0) {
3981
+ writeLine(streams.stdout, `${entry.soul.agent.name} already has all of these enabled.`);
3982
+ return 0;
3983
+ }
3984
+ // A field edit renders through formatSoul, which canonicalizes the
3985
+ // file — same trade the control API's field edits make.
3986
+ const next = {
3987
+ ...entry.soul,
3988
+ agent: { ...entry.soul.agent, skills: [...existing, ...additions] },
3989
+ };
3990
+ await writeFile(entry.path, formatSoul(next));
3991
+ writeLine(streams.stdout, `enabled for ${next.agent.name} (${entry.path}): ${additions.join(', ')}`);
3992
+ return 0;
3993
+ }
3994
+ finally {
3995
+ await cleanup?.();
3996
+ }
3997
+ };
3998
+ /**
3999
+ * List and cancel what the fleet has scheduled — against the daemon's own
4000
+ * database, the way `stratus logs` reads the daemon's own log. WAL makes a
4001
+ * second process on the file routine; a running daemon re-reads due rows
4002
+ * every tick and the destination grant on every send, so a cancel from
4003
+ * here takes effect without asking it anything.
4004
+ */
4005
+ export const runSchedules = async (command, streams, env = {}) => {
4006
+ // Lazy like the serve path: node:sqlite loads only for the command that
4007
+ // needs it.
4008
+ const { SqliteScheduleStore, defaultSessionDbPath } = await import('@stratusagent/gateway');
4009
+ const store = new SqliteScheduleStore(defaultSessionDbPath(env));
4010
+ try {
4011
+ if (command.action === 'cancel') {
4012
+ const id = command.scheduleId ?? '';
4013
+ const record = store.get(id);
4014
+ if (!record || !store.delete(id)) {
4015
+ writeLine(streams.stderr, `No schedule with id ${id}. \`stratus schedules\` lists what exists.`);
4016
+ return 1;
4017
+ }
4018
+ writeLine(streams.stdout, `Cancelled ${id} (${record.agentId}, ${describeCadence(record.cadence)}).`);
4019
+ if (record.destination) {
4020
+ writeLine(streams.stdout, `Its pre-authorized destination ${canonicalDestination(record.destination)} is revoked with it.`);
4021
+ }
4022
+ return 0;
4023
+ }
4024
+ const schedules = store.list();
4025
+ if (command.format === 'json') {
4026
+ writeLine(streams.stdout, JSON.stringify({ schedules: schedules.map(describeSchedule) }, null, 2));
4027
+ return 0;
4028
+ }
4029
+ if (schedules.length === 0) {
4030
+ writeLine(streams.stdout, 'No schedules set. An agent sets one with the schedule.every / schedule.at tools.');
4031
+ return 0;
4032
+ }
4033
+ for (const record of schedules) {
4034
+ const destination = record.destination ? ` → ${canonicalDestination(record.destination)}` : '';
4035
+ writeLine(streams.stdout, `${record.id} [${record.agentId}] ${describeCadence(record.cadence)}${destination}`);
4036
+ writeLine(streams.stdout, ` next: ${record.nextFireAt ?? '(spent — awaiting cleanup)'}${record.lastFiredAt ? ` last: ${record.lastFiredAt}` : ''}`);
4037
+ writeLine(streams.stdout, ` prompt: ${record.prompt}`);
4038
+ }
4039
+ return 0;
4040
+ }
4041
+ finally {
4042
+ store.close();
4043
+ }
4044
+ };
4045
+ export const runSkills = async (streams, env = {}) => {
4046
+ const registry = new SkillRegistry();
4047
+ const skills = await loadOperatorSkills(env, registry, (line) => {
4048
+ writeLine(streams.stderr, `Warning: ${line}`);
4049
+ });
4050
+ if (skills.length === 0) {
4051
+ writeLine(streams.stdout, `No skills installed in ${skillsDirPath(env)}.`);
4052
+ writeLine(streams.stdout, 'Install some: stratus skill add <owner/repo | git url | path>');
4053
+ return 0;
4054
+ }
4055
+ // Who has each skill enabled, so the listing answers the question that
4056
+ // follows "what is installed" — read from the same roster a dispatch
4057
+ // serves. Plugin-contributed skills are the daemon's to list
4058
+ // (/catalog/tools); this command reads the operator directory.
4059
+ //
4060
+ // A roster that will not load (a duplicate agent id, deliberately
4061
+ // fatal) is unreadable enablement, not empty enablement: say so and
4062
+ // withhold the claim rather than reporting every skill unused.
4063
+ let roster = [];
4064
+ let rosterUnreadable = false;
4065
+ try {
4066
+ const resolved = await rosterSoulsWithConfigured(env, (line) => writeLine(streams.stderr, `Warning: ${line}`));
4067
+ roster = resolved.entries;
4068
+ rosterUnreadable = !resolved.complete;
4069
+ }
4070
+ catch (error) {
4071
+ rosterUnreadable = true;
4072
+ writeLine(streams.stderr, `Warning: cannot say who enables what — the roster did not load: ${error instanceof Error ? error.message : String(error)}`);
4073
+ }
4074
+ for (const skill of skills) {
4075
+ let suffix = '';
4076
+ if (!rosterUnreadable) {
4077
+ const enabledBy = roster
4078
+ .filter((entry) => {
4079
+ const allowlist = entry.soul.agent.skills;
4080
+ return allowlist !== undefined
4081
+ && allowlist.length > 0
4082
+ && matchesSkillAllowlist(skill.id, allowlist);
4083
+ })
4084
+ .map((entry) => entry.soul.agent.name);
4085
+ suffix = enabledBy.length > 0 ? ` — enabled by ${enabledBy.join(', ')}` : ' — enabled by nobody yet';
4086
+ }
4087
+ writeLine(streams.stdout, `${skill.id.padEnd(24)}${skill.description}${suffix}`);
4088
+ }
4089
+ return 0;
4090
+ };
3500
4091
  export const runAgentNew = async (command, streams, env = {}) => {
3501
4092
  // On a real terminal, creating an agent is the same guided experience as
3502
4093
  // setup: a headed screen, a prefilled (editable) name, a personality, and
@@ -3533,14 +4124,12 @@ export const runAgentNew = async (command, streams, env = {}) => {
3533
4124
  writeLine(streams.stdout, `Note: ${message}.`);
3534
4125
  });
3535
4126
  const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
3536
- ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
3537
4127
  ?? activeConfig.provider
3538
4128
  ?? 'demo';
3539
4129
  // The active config's model was written for the provider named in
3540
4130
  // that config; it only travels into the soul when they still match.
3541
4131
  const configModelApplies = (activeConfig.provider ?? 'openai') === soulProvider;
3542
4132
  const soulModel = readNonEmptyString(processEnv.STRATUS_MODEL)
3543
- ?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
3544
4133
  ?? (configModelApplies ? activeConfig.model : undefined)
3545
4134
  ?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
3546
4135
  const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
@@ -3787,6 +4376,31 @@ export const runDashboard = async (command, streams, env = {}) => {
3787
4376
  }
3788
4377
  return serving;
3789
4378
  };
4379
+ /**
4380
+ * Resolution and loading are separate questions, and only the first one
4381
+ * means "not installed". Inspecting an import's error message cannot tell
4382
+ * them apart: a package that IS installed but is missing one of its own
4383
+ * dependencies throws ERR_MODULE_NOT_FOUND naming that dependency and the
4384
+ * importer — so a broken install would read as an absent one, silently
4385
+ * disabling a channel whose stored tokens say it should be running, or
4386
+ * leaving a daemon without the API an operator installed it to have.
4387
+ */
4388
+ const defaultPackageResolver = (specifier) => {
4389
+ try {
4390
+ import.meta.resolve(specifier);
4391
+ return true;
4392
+ }
4393
+ catch {
4394
+ return false;
4395
+ }
4396
+ };
4397
+ /**
4398
+ * Whether an optional package is installed. The resolver is injectable
4399
+ * because what is installed is a property of the machine: a test asserting
4400
+ * on the real one would be asserting on its own node_modules, which is why
4401
+ * every optional package is a devDependency here in the first place.
4402
+ */
4403
+ const packageInstalled = (specifier, env = {}) => (env.packageResolver ?? defaultPackageResolver)(specifier);
3790
4404
  /**
3791
4405
  * Loads the optional Slack channel package, or undefined when it is not
3792
4406
  * installed. Only that package being absent is tolerated: an adapter that
@@ -3795,17 +4409,7 @@ export const runDashboard = async (command, streams, env = {}) => {
3795
4409
  * whose stored tokens say it should be running.
3796
4410
  */
3797
4411
  const loadSlackAdapter = async () => {
3798
- // Resolution and loading are separate questions, and only the first one
3799
- // means "not installed". Inspecting the import's error message cannot
3800
- // tell them apart: a package that IS installed but is missing one of
3801
- // its own dependencies throws ERR_MODULE_NOT_FOUND naming that
3802
- // dependency and this package as the importer — a broken install would
3803
- // read as an absent one, silently disabling a channel whose tokens say
3804
- // it should be running.
3805
- try {
3806
- import.meta.resolve('@stratusagent/channel-slack');
3807
- }
3808
- catch {
4412
+ if (!packageInstalled('@stratusagent/channel-slack')) {
3809
4413
  return undefined;
3810
4414
  }
3811
4415
  // Resolvable: any failure from here is a real problem with the
@@ -3822,10 +4426,7 @@ const loadSlackAdapter = async () => {
3822
4426
  * without the API an operator installed it to have.
3823
4427
  */
3824
4428
  const loadControlApi = async () => {
3825
- try {
3826
- import.meta.resolve('@stratusagent/control-api');
3827
- }
3828
- catch {
4429
+ if (!packageInstalled('@stratusagent/control-api')) {
3829
4430
  return undefined;
3830
4431
  }
3831
4432
  return (await import('@stratusagent/control-api')).createControlApi;
@@ -4058,9 +4659,14 @@ export const runServe = async (command, streams, env = {}) => {
4058
4659
  log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
4059
4660
  },
4060
4661
  };
4061
- const approvals = (transport) => createPermissionPolicy(approvalMode === 'remote'
4062
- ? { mode: 'remote', request: transport.request, onDecision, commands }
4063
- : { mode: 'headless', onDecision, commands });
4662
+ const approvals = (transport) => createPermissionPolicy(
4663
+ // The destination scope rides along in BOTH modes — it is what lets a
4664
+ // scheduled turn report to the channel a human approved with the
4665
+ // schedule, and headless (where every other gated call is refused) is
4666
+ // exactly the deployment it exists for.
4667
+ approvalMode === 'remote'
4668
+ ? { mode: 'remote', request: transport.request, onDecision, commands, destinations: transport.destinations }
4669
+ : { mode: 'headless', onDecision, commands, destinations: transport.destinations });
4064
4670
  if (approvalMode === 'remote') {
4065
4671
  // Only agents whose channel actually came up can be asked: tokens on
4066
4672
  // disk with the Slack package missing means nothing renders the
@@ -4245,6 +4851,15 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
4245
4851
  if (command.command === 'agents') {
4246
4852
  return await runAgents(command, streams, resolvedEnv);
4247
4853
  }
4854
+ if (command.command === 'skill-add') {
4855
+ return await runSkillAdd(command, streams, resolvedEnv);
4856
+ }
4857
+ if (command.command === 'skills') {
4858
+ return await runSkills(streams, resolvedEnv);
4859
+ }
4860
+ if (command.command === 'schedules') {
4861
+ return await runSchedules(command, streams, resolvedEnv);
4862
+ }
4248
4863
  if (command.command === 'doctor') {
4249
4864
  return await runDoctor(command, streams, resolvedEnv);
4250
4865
  }