@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/README.md +249 -8
- package/dist/index.d.ts +74 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +715 -100
- package/dist/index.js.map +1 -1
- package/package.json +24 -19
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
828
|
-
//
|
|
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
|
|
1672
|
-
//
|
|
1673
|
-
//
|
|
1674
|
-
const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG)
|
|
1675
|
-
|
|
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,
|
|
2599
|
-
|
|
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[
|
|
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
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
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',
|
|
3044
|
+
detectEnvOverride('STRATUS_PROVIDER', state.provider, '--provider'),
|
|
2738
3045
|
...(state.provider !== 'demo'
|
|
2739
|
-
? [detectEnvOverride('STRATUS_MODEL',
|
|
3046
|
+
? [detectEnvOverride('STRATUS_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
|
|
2740
3047
|
: []),
|
|
2741
3048
|
...(state.provider === 'openai'
|
|
2742
|
-
? [detectEnvOverride('STRATUS_BASE_URL',
|
|
2743
|
-
: [detectEnvOverride('STRATUS_BASE_URL', '
|
|
2744
|
-
detectEnvOverride('STRATUS_SYSTEM_PROMPT',
|
|
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
|
-
|
|
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
|
|
2953
|
-
// fix STRATUS_CONFIG when
|
|
2954
|
-
//
|
|
2955
|
-
//
|
|
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
|
-
:
|
|
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.
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
4062
|
-
|
|
4063
|
-
|
|
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
|
}
|