@stratusagent/cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -1
- package/dist/index.d.ts +45 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +490 -92
- 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, 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
20
|
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,
|
|
21
|
+
import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
|
|
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,9 @@ 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
|
|
83
130
|
stratus doctor
|
|
84
131
|
stratus service install
|
|
85
132
|
stratus service status
|
|
@@ -93,7 +140,9 @@ Commands:
|
|
|
93
140
|
subscription or API key), create your agent, connect it to
|
|
94
141
|
Slack, and test it — settings go to ~/.stratus/config.json,
|
|
95
142
|
sign-ins and channel tokens to ~/.stratus/credentials.json
|
|
96
|
-
(0600)
|
|
143
|
+
(0600). Save & finish offers to install any optional
|
|
144
|
+
package your answers imply (the Slack channel, the control
|
|
145
|
+
API and dashboard) before it starts the daemon
|
|
97
146
|
chat Talk with your agent — the conversation persists across turns
|
|
98
147
|
and remembered facts accumulate; /exit or Ctrl+C to leave
|
|
99
148
|
run Execute one local Stratus Agent session
|
|
@@ -115,6 +164,15 @@ Commands:
|
|
|
115
164
|
logs Read the daemon's log from any terminal: -f to follow,
|
|
116
165
|
-n <count> for backlog, --agent / --session to filter,
|
|
117
166
|
--format json for the raw records
|
|
167
|
+
skill add Install skills from a GitHub repo (owner/repo or URL) or a
|
|
168
|
+
local path into ~/.stratus/skills — whole directories, one
|
|
169
|
+
per skill; works with skills published for other agents
|
|
170
|
+
(skills.sh-style repos). Installed is not enabled: a soul
|
|
171
|
+
opts in via skills:, or pass --agent <id> to enable now.
|
|
172
|
+
--skill <id> picks from a multi-skill repo (repeatable),
|
|
173
|
+
--force replaces an already-installed id
|
|
174
|
+
skills List installed skills and which agents enable each
|
|
175
|
+
(also: stratus skill list)
|
|
118
176
|
agent new Create an agent identity (generates a human-ish name + avatar theme)
|
|
119
177
|
agents List your agents: who they are, where their souls live, what
|
|
120
178
|
they run on, what they remember (also: stratus agent list).
|
|
@@ -163,7 +221,6 @@ Config file:
|
|
|
163
221
|
The CLI looks for ./stratus.config.json first, then a path from --config / STRATUS_CONFIG,
|
|
164
222
|
then the global ~/.stratus/config.json written by \`stratus setup\`.
|
|
165
223
|
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
224
|
|
|
168
225
|
Plugins (tools):
|
|
169
226
|
Capability is optional: install a package, then list it under "plugins" in a
|
|
@@ -183,10 +240,13 @@ Plugins (tools):
|
|
|
183
240
|
Installing one grants no agent anything — each soul lists what it may call.
|
|
184
241
|
|
|
185
242
|
Soul files:
|
|
186
|
-
A soul file is markdown with frontmatter (name, provider, model, tools, credentials)
|
|
243
|
+
A soul file is markdown with frontmatter (name, provider, model, tools, skills, credentials)
|
|
187
244
|
followed by the agent's persona in prose. See examples/souls/ava.md.
|
|
188
245
|
"tools" takes exact names or a whole toolset: tools: [fs.read, fs.search] or
|
|
189
246
|
tools: [fs.*]. Omitted means every registered tool.
|
|
247
|
+
"skills" is the same allowlist shape over installed skills (see stratus
|
|
248
|
+
skills), except omitted means none — a skill is enabled per agent, never
|
|
249
|
+
by being installed.
|
|
190
250
|
`;
|
|
191
251
|
const writeLine = (stream, line = '') => {
|
|
192
252
|
stream.write(`${line}\n`);
|
|
@@ -548,6 +608,69 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
548
608
|
...(sessionId ? { sessionId } : {}),
|
|
549
609
|
};
|
|
550
610
|
}
|
|
611
|
+
if (command === 'skills' || (command === 'skill' && rest[0] === 'list')) {
|
|
612
|
+
const skillsRest = command === 'skills' ? rest : rest.slice(1);
|
|
613
|
+
for (const token of skillsRest) {
|
|
614
|
+
if (token === '--help' || token === '-h') {
|
|
615
|
+
return { command: 'help' };
|
|
616
|
+
}
|
|
617
|
+
throw new Error(`Unknown option: ${token}`);
|
|
618
|
+
}
|
|
619
|
+
return { command: 'skills' };
|
|
620
|
+
}
|
|
621
|
+
if (command === 'skill') {
|
|
622
|
+
const [subcommand, ...skillRest] = rest;
|
|
623
|
+
if (subcommand === '--help' || subcommand === '-h') {
|
|
624
|
+
return { command: 'help' };
|
|
625
|
+
}
|
|
626
|
+
if (subcommand !== 'add') {
|
|
627
|
+
throw new Error(`Unknown skill subcommand: ${subcommand ?? '(missing)'}. Try: stratus skill add <source>, stratus skills`);
|
|
628
|
+
}
|
|
629
|
+
let source;
|
|
630
|
+
const skillIds = [];
|
|
631
|
+
let force = false;
|
|
632
|
+
let agentId;
|
|
633
|
+
for (let index = 0; index < skillRest.length; index += 1) {
|
|
634
|
+
const token = skillRest[index];
|
|
635
|
+
if (!token) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (token === '--help' || token === '-h') {
|
|
639
|
+
return { command: 'help' };
|
|
640
|
+
}
|
|
641
|
+
if (token === '--skill') {
|
|
642
|
+
skillIds.push(readOptionValue(skillRest, index, '--skill'));
|
|
643
|
+
index += 1;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (token === '--agent') {
|
|
647
|
+
agentId = readOptionValue(skillRest, index, '--agent');
|
|
648
|
+
index += 1;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (token === '--force') {
|
|
652
|
+
force = true;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (token.startsWith('--')) {
|
|
656
|
+
throw new Error(`Unknown option: ${token}`);
|
|
657
|
+
}
|
|
658
|
+
if (source !== undefined) {
|
|
659
|
+
throw new Error(`skill add takes one source; got both ${JSON.stringify(source)} and ${JSON.stringify(token)}.`);
|
|
660
|
+
}
|
|
661
|
+
source = token;
|
|
662
|
+
}
|
|
663
|
+
if (source === undefined) {
|
|
664
|
+
throw new Error('skill add needs a source: a GitHub owner/repo, a git URL, or a local path.');
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
command: 'skill-add',
|
|
668
|
+
source,
|
|
669
|
+
...(skillIds.length > 0 ? { skillIds } : {}),
|
|
670
|
+
...(force ? { force } : {}),
|
|
671
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
551
674
|
if (command === 'agent') {
|
|
552
675
|
const [subcommand, ...agentRest] = rest;
|
|
553
676
|
if (subcommand === '--help' || subcommand === '-h') {
|
|
@@ -824,9 +947,8 @@ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
|
|
|
824
947
|
return;
|
|
825
948
|
}
|
|
826
949
|
// 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.
|
|
950
|
+
// would name the wrong one whenever a custom apiKeyEnv supplied the key,
|
|
951
|
+
// sending the reader to unset something that was never the cause.
|
|
830
952
|
const primary = runtime.provider === 'anthropic' && runtime.apiKey ? runtime.apiKeyEnvVar : undefined;
|
|
831
953
|
// A fallback demoted the same way costs exactly as much, and only bites
|
|
832
954
|
// once the primary is already failing — the worst moment to discover it.
|
|
@@ -897,6 +1019,15 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
897
1019
|
const tools = new ToolRegistry();
|
|
898
1020
|
tools.register(createDemoTool());
|
|
899
1021
|
tools.register(createRememberTool(memory));
|
|
1022
|
+
// The same skills the daemon would serve, from the same directory, for
|
|
1023
|
+
// the same reason the plugins below match: a skill that routes in
|
|
1024
|
+
// `stratus run` routes in `stratus serve`, and one that is broken is
|
|
1025
|
+
// broken (and warned about) in both. The runner registers `skill.read`
|
|
1026
|
+
// itself, gated on the soul enabling any skill.
|
|
1027
|
+
const skills = new SkillRegistry();
|
|
1028
|
+
await loadOperatorSkills(runEnv, skills, (line) => {
|
|
1029
|
+
writeLine(streams.stderr, `Warning: ${line}`);
|
|
1030
|
+
});
|
|
900
1031
|
const bus = new EventBus({
|
|
901
1032
|
onError: (error) => {
|
|
902
1033
|
writeLine(streams.stderr, `Warning: event handler failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -932,6 +1063,7 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
932
1063
|
import: (specifier) => import(__rewriteRelativeImportExtension(specifier)),
|
|
933
1064
|
},
|
|
934
1065
|
tools,
|
|
1066
|
+
skills,
|
|
935
1067
|
bus,
|
|
936
1068
|
workspaceRoot: workspacesDirPath(runEnv),
|
|
937
1069
|
});
|
|
@@ -959,6 +1091,7 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
959
1091
|
executor: createLocalCommandExecutor(),
|
|
960
1092
|
approvals: createApprovalPolicy(options.approvals ?? 'always', streams, options.env ?? {}, options.askApproval),
|
|
961
1093
|
bus,
|
|
1094
|
+
skills,
|
|
962
1095
|
memory,
|
|
963
1096
|
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
964
1097
|
});
|
|
@@ -967,6 +1100,13 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
967
1100
|
// A soul is a full identity — without one, every provider serves the
|
|
968
1101
|
// same built-in Stratus persona.
|
|
969
1102
|
const agent = options.runtime.soul?.agent ?? DEFAULT_STRATUS_AGENT;
|
|
1103
|
+
// The same advisory the daemon gives at roster load, from the same
|
|
1104
|
+
// kernel check: a soul enabling a skill whose `requires:` its `tools:`
|
|
1105
|
+
// does not cover must warn here too, or the local test stays silent
|
|
1106
|
+
// about a configuration `stratus serve` flags.
|
|
1107
|
+
for (const { skill, missing } of missingSkillRequirements(agent, skills)) {
|
|
1108
|
+
writeLine(streams.stderr, `Warning: agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
|
|
1109
|
+
}
|
|
970
1110
|
const metadata = options.runtime.provider === 'demo'
|
|
971
1111
|
? { provider: 'demo', executor: 'local-command' }
|
|
972
1112
|
: {
|
|
@@ -1668,15 +1808,11 @@ const DEFAULT_SOUL_STARTER = [
|
|
|
1668
1808
|
export const runSetup = async (command, streams, env = {}) => {
|
|
1669
1809
|
const cwd = readWorkingDirectory(env);
|
|
1670
1810
|
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;
|
|
1811
|
+
// Config target: --config, then STRATUS_CONFIG, then the global
|
|
1812
|
+
// ~/.stratus/config.json — the file `stratus run` falls back to from any
|
|
1813
|
+
// directory, which is what makes setup a one-time step.
|
|
1814
|
+
const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG) ? 'STRATUS_CONFIG' : undefined;
|
|
1815
|
+
const envConfigPath = envConfigVar ? String(processEnv.STRATUS_CONFIG).trim() : undefined;
|
|
1680
1816
|
const configPath = command.configPath
|
|
1681
1817
|
? path.resolve(cwd, command.configPath)
|
|
1682
1818
|
: envConfigPath
|
|
@@ -1989,7 +2125,6 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1989
2125
|
}
|
|
1990
2126
|
const keyEnvSelector = provider === state.provider
|
|
1991
2127
|
? readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
|
|
1992
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
|
|
1993
2128
|
?? state.apiKeyEnv
|
|
1994
2129
|
: undefined;
|
|
1995
2130
|
return state.credentials[provider] !== undefined
|
|
@@ -2246,11 +2381,9 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2246
2381
|
// stored key's bound endpoint is authoritative — so the inline test
|
|
2247
2382
|
// exercises precisely what a real run will use.
|
|
2248
2383
|
const keyEnv = readNonEmptyString(processEnv.STRATUS_API_KEY_ENV)
|
|
2249
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY_ENV)
|
|
2250
2384
|
?? state.apiKeyEnv
|
|
2251
2385
|
?? defaultKeyEnvFor(state.provider);
|
|
2252
2386
|
const envKey = readNonEmptyString(processEnv.STRATUS_API_KEY)
|
|
2253
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_API_KEY)
|
|
2254
2387
|
?? readNonEmptyString(processEnv[String(keyEnv)]);
|
|
2255
2388
|
const credential = envKey ? undefined : state.credentials[state.provider];
|
|
2256
2389
|
const boundUrl = credential?.type === 'api_key' ? credential.baseUrl : undefined;
|
|
@@ -2409,8 +2542,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2409
2542
|
// Listing the config soul while `stratus serve` registers the env one
|
|
2410
2543
|
// would store tokens against an id the adapter then skips.
|
|
2411
2544
|
const processEnv = readProcessEnv(env);
|
|
2412
|
-
const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL)
|
|
2413
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL);
|
|
2545
|
+
const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL);
|
|
2414
2546
|
if (typeof envSoul === 'string' && state.soulPath && envSoul !== state.soulPath) {
|
|
2415
2547
|
warnOnce(`STRATUS_SOUL points at ${envSoul}, which outranks the configured ${state.soulPath} — Channels lists what a run would actually use`);
|
|
2416
2548
|
}
|
|
@@ -2595,25 +2727,126 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2595
2727
|
}
|
|
2596
2728
|
}
|
|
2597
2729
|
};
|
|
2598
|
-
const detectEnvOverride = (primary,
|
|
2599
|
-
|
|
2600
|
-
? primary
|
|
2601
|
-
: readNonEmptyString(processEnv[legacy])
|
|
2602
|
-
? legacy
|
|
2603
|
-
: undefined;
|
|
2604
|
-
if (!envVar) {
|
|
2730
|
+
const detectEnvOverride = (primary, chosen, flagName) => {
|
|
2731
|
+
if (!readNonEmptyString(processEnv[primary])) {
|
|
2605
2732
|
return undefined;
|
|
2606
2733
|
}
|
|
2607
|
-
const envValue = String(processEnv[
|
|
2734
|
+
const envValue = String(processEnv[primary]).trim();
|
|
2608
2735
|
if (envValue === chosen) {
|
|
2609
2736
|
return undefined;
|
|
2610
2737
|
}
|
|
2611
2738
|
return {
|
|
2612
|
-
envVar,
|
|
2739
|
+
envVar: primary,
|
|
2613
2740
|
envValue,
|
|
2614
2741
|
...(flagName ? { flag: `${flagName} ${quoteShellArg(chosen)}` } : {}),
|
|
2615
2742
|
};
|
|
2616
2743
|
};
|
|
2744
|
+
/**
|
|
2745
|
+
* Whether `stratus dashboard` can work. Read before the offer and set by
|
|
2746
|
+
* it, so the closing suggestions never name a command this machine cannot
|
|
2747
|
+
* run — the failure that sends someone to the logs to find out why.
|
|
2748
|
+
*/
|
|
2749
|
+
let dashboardReady = packageInstalled('@stratusagent/control-api', env)
|
|
2750
|
+
&& packageInstalled('@stratusagent/dashboard', env);
|
|
2751
|
+
const missingPackageGroups = () => {
|
|
2752
|
+
const groups = [];
|
|
2753
|
+
const slackAgents = Object.keys(state.channels.slack ?? {}).length;
|
|
2754
|
+
if (slackAgents > 0 && !packageInstalled('@stratusagent/channel-slack', env)) {
|
|
2755
|
+
groups.push({
|
|
2756
|
+
id: 'slack',
|
|
2757
|
+
label: 'Slack channel',
|
|
2758
|
+
why: `Slack tokens are stored for ${slackAgents} agent(s), but nothing connects to Slack without it`,
|
|
2759
|
+
packages: ['@stratusagent/channel-slack'],
|
|
2760
|
+
});
|
|
2761
|
+
}
|
|
2762
|
+
// Both, because the dashboard is what was offered: the control API is
|
|
2763
|
+
// the port and the dashboard is the page served on it. Only the missing
|
|
2764
|
+
// half is installed, so accepting this on a machine that already has
|
|
2765
|
+
// the API does not reinstall it.
|
|
2766
|
+
const dashboardPackages = ['@stratusagent/control-api', '@stratusagent/dashboard']
|
|
2767
|
+
.filter((name) => !packageInstalled(name, env));
|
|
2768
|
+
if (dashboardPackages.length > 0) {
|
|
2769
|
+
groups.push({
|
|
2770
|
+
id: 'dashboard',
|
|
2771
|
+
label: 'Web dashboard',
|
|
2772
|
+
why: '`stratus dashboard` needs it, and it opens an authenticated port on 127.0.0.1',
|
|
2773
|
+
packages: dashboardPackages,
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
return groups;
|
|
2777
|
+
};
|
|
2778
|
+
/**
|
|
2779
|
+
* Offers the packages above. Declining is a real answer and prints the
|
|
2780
|
+
* command; the control API in particular binds a port, and installing it
|
|
2781
|
+
* is how an operator says they want one open, so this asks rather than
|
|
2782
|
+
* deciding for them.
|
|
2783
|
+
*/
|
|
2784
|
+
const offerOptionalPackages = async () => {
|
|
2785
|
+
const groups = missingPackageGroups();
|
|
2786
|
+
if (groups.length === 0) {
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
writeLine(streams.stdout);
|
|
2790
|
+
writeLine(streams.stdout, groups.length === 1
|
|
2791
|
+
? 'One optional package is not installed:'
|
|
2792
|
+
: `${groups.length} optional packages are not installed:`);
|
|
2793
|
+
for (const group of groups) {
|
|
2794
|
+
writeLine(streams.stdout, ` ${group.packages.join(' ')}`);
|
|
2795
|
+
writeLine(streams.stdout, ` ${group.why}.`);
|
|
2796
|
+
}
|
|
2797
|
+
const answer = await prompter.select('Install now with npm install -g?', groups.length === 1
|
|
2798
|
+
? ['Install it now', 'Skip']
|
|
2799
|
+
: ['Install all of them now', ...groups.map((group) => `Install the ${group.label} only`), 'Skip']);
|
|
2800
|
+
const chosen = (() => {
|
|
2801
|
+
if (answer.kind !== 'index') {
|
|
2802
|
+
return [];
|
|
2803
|
+
}
|
|
2804
|
+
if (groups.length === 1) {
|
|
2805
|
+
return answer.index === 0 ? groups : [];
|
|
2806
|
+
}
|
|
2807
|
+
if (answer.index === 0) {
|
|
2808
|
+
return groups;
|
|
2809
|
+
}
|
|
2810
|
+
const only = groups[answer.index - 1];
|
|
2811
|
+
return only ? [only] : [];
|
|
2812
|
+
})();
|
|
2813
|
+
// Held before the install rather than derived after it: picking one
|
|
2814
|
+
// group is not a decision about the other, and the daemon starts a few
|
|
2815
|
+
// lines below and warns about exactly what is still missing — the
|
|
2816
|
+
// notice this whole offer exists to pre-empt.
|
|
2817
|
+
const declined = groups.filter((group) => !chosen.includes(group));
|
|
2818
|
+
if (chosen.length > 0) {
|
|
2819
|
+
const packages = chosen.flatMap((group) => group.packages);
|
|
2820
|
+
writeLine(streams.stdout, `Running: npm install -g ${packages.join(' ')}`);
|
|
2821
|
+
// Never fails setup, for the same reason the service install does not:
|
|
2822
|
+
// the config and credentials are already written, and a package that
|
|
2823
|
+
// did not install is a warning at the next start, not a broken machine.
|
|
2824
|
+
const result = await (env.packageInstaller ?? defaultPackageInstaller)(packages)
|
|
2825
|
+
.catch((error) => ({
|
|
2826
|
+
ok: false,
|
|
2827
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2828
|
+
}));
|
|
2829
|
+
if (result.ok) {
|
|
2830
|
+
writeLine(streams.stdout, `Installed ${packages.join(' ')}.`);
|
|
2831
|
+
// npm's exit code, not a second resolve: a package written into the
|
|
2832
|
+
// global prefix a moment ago need not be resolvable from THIS
|
|
2833
|
+
// process, whose module resolution was fixed when it started.
|
|
2834
|
+
if (chosen.some((group) => group.id === 'dashboard')) {
|
|
2835
|
+
dashboardReady = true;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
else {
|
|
2839
|
+
writeLine(streams.stderr, `Could not install: ${result.message}`);
|
|
2840
|
+
writeLine(streams.stderr, `Setup is saved either way — run \`npm install -g ${packages.join(' ')}\` yourself.`);
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
if (declined.length > 0) {
|
|
2844
|
+
const rest = declined.flatMap((group) => group.packages).join(' ');
|
|
2845
|
+
writeLine(streams.stdout, chosen.length > 0
|
|
2846
|
+
? `Still missing. Install with: npm install -g ${rest}`
|
|
2847
|
+
: `Skipped. Install them yourself with: npm install -g ${rest}`);
|
|
2848
|
+
}
|
|
2849
|
+
};
|
|
2617
2850
|
const save = async () => {
|
|
2618
2851
|
const config = { provider: state.provider };
|
|
2619
2852
|
if (state.provider !== 'demo') {
|
|
@@ -2650,19 +2883,16 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2650
2883
|
// bare runs started here — say so, and make the suggested command pick
|
|
2651
2884
|
// the file that was just written.
|
|
2652
2885
|
if (configPath === globalConfigPath(env)) {
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
if (error.code !== 'ENOENT') {
|
|
2664
|
-
throw error;
|
|
2665
|
-
}
|
|
2886
|
+
const shadowPath = path.join(cwd, DEFAULT_CONFIG_FILENAME);
|
|
2887
|
+
try {
|
|
2888
|
+
await readFile(shadowPath, 'utf8');
|
|
2889
|
+
writeLine(streams.stdout, `Note: ${shadowPath} exists and takes precedence over the global config for runs started in this directory.`);
|
|
2890
|
+
writeLine(streams.stdout, 'The suggested commands below include --config so they use what you just saved.');
|
|
2891
|
+
shadowConfigFlag = ` --config ${quoteShellArg(configPath)}`;
|
|
2892
|
+
}
|
|
2893
|
+
catch (error) {
|
|
2894
|
+
if (error.code !== 'ENOENT') {
|
|
2895
|
+
throw error;
|
|
2666
2896
|
}
|
|
2667
2897
|
}
|
|
2668
2898
|
}
|
|
@@ -2679,6 +2909,13 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2679
2909
|
? `Saved Slack tokens for ${connected} agent${connected === 1 ? '' : 's'} to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`
|
|
2680
2910
|
: `Removed the stored Slack tokens from ${credentialsPath(env)}.`);
|
|
2681
2911
|
}
|
|
2912
|
+
// Before the service block below, deliberately. A package installed
|
|
2913
|
+
// after the daemon starts is invisible to it — installing does not
|
|
2914
|
+
// reload a running process — so offering here is what makes the
|
|
2915
|
+
// LaunchAgent come up with the Slack channel and the control API
|
|
2916
|
+
// already present, instead of warning about them in a log nobody reads
|
|
2917
|
+
// until the dashboard fails.
|
|
2918
|
+
await offerOptionalPackages();
|
|
2682
2919
|
// Last, so the daemon starts against the config and credentials that
|
|
2683
2920
|
// were just written rather than the ones it would have found a moment
|
|
2684
2921
|
// ago. A service failure is reported and never fails setup: the
|
|
@@ -2734,14 +2971,14 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2734
2971
|
// Exported STRATUS_* variables outrank the config file, so warn when one
|
|
2735
2972
|
// would make `stratus run` behave differently from what was just saved.
|
|
2736
2973
|
const conflicts = [
|
|
2737
|
-
detectEnvOverride('STRATUS_PROVIDER',
|
|
2974
|
+
detectEnvOverride('STRATUS_PROVIDER', state.provider, '--provider'),
|
|
2738
2975
|
...(state.provider !== 'demo'
|
|
2739
|
-
? [detectEnvOverride('STRATUS_MODEL',
|
|
2976
|
+
? [detectEnvOverride('STRATUS_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
|
|
2740
2977
|
: []),
|
|
2741
2978
|
...(state.provider === 'openai'
|
|
2742
|
-
? [detectEnvOverride('STRATUS_BASE_URL',
|
|
2743
|
-
: [detectEnvOverride('STRATUS_BASE_URL', '
|
|
2744
|
-
detectEnvOverride('STRATUS_SYSTEM_PROMPT',
|
|
2979
|
+
? [detectEnvOverride('STRATUS_BASE_URL', state.baseUrl ?? DEFAULT_OPENAI_BASE_URL, '--base-url')]
|
|
2980
|
+
: [detectEnvOverride('STRATUS_BASE_URL', '')]),
|
|
2981
|
+
detectEnvOverride('STRATUS_SYSTEM_PROMPT', state.systemPrompt ?? ''),
|
|
2745
2982
|
].filter((conflict) => conflict !== undefined);
|
|
2746
2983
|
for (const conflict of conflicts) {
|
|
2747
2984
|
writeLine(streams.stdout, `Note: ${conflict.envVar}=${conflict.envValue} is exported and takes precedence over the config file (run \`unset ${conflict.envVar}\` to clear it).`);
|
|
@@ -2775,7 +3012,9 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2775
3012
|
}
|
|
2776
3013
|
}
|
|
2777
3014
|
writeLine(streams.stdout, ` stratus run${extraFlags}${runConfigFlag}${shadowConfigFlag} "say hello"`);
|
|
2778
|
-
|
|
3015
|
+
if (dashboardReady) {
|
|
3016
|
+
writeLine(streams.stdout, ' stratus dashboard');
|
|
3017
|
+
}
|
|
2779
3018
|
};
|
|
2780
3019
|
try {
|
|
2781
3020
|
if (!interactive) {
|
|
@@ -2949,23 +3188,20 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
|
|
|
2949
3188
|
// Config discovery, spelled out rather than delegated: doctor has to
|
|
2950
3189
|
// report the files that LOST as well as the one in use, which is the
|
|
2951
3190
|
// whole point when a stray project config is the answer.
|
|
2952
|
-
// Which of the
|
|
2953
|
-
// fix STRATUS_CONFIG when
|
|
2954
|
-
//
|
|
2955
|
-
//
|
|
3191
|
+
// Which of the two named it, not just that one did: telling someone to
|
|
3192
|
+
// fix STRATUS_CONFIG when --config is what is set leaves the real
|
|
3193
|
+
// override in place — the same mistake as provider and key attribution,
|
|
3194
|
+
// in the one place a typo is most likely.
|
|
2956
3195
|
const explicitSource = command.configPath !== undefined
|
|
2957
3196
|
? { name: '--config', value: command.configPath }
|
|
2958
3197
|
: readNonEmptyString(processEnv.STRATUS_CONFIG)
|
|
2959
3198
|
? { name: 'STRATUS_CONFIG', value: String(processEnv.STRATUS_CONFIG) }
|
|
2960
|
-
:
|
|
2961
|
-
? { name: 'STRATUSCLAW_CONFIG', value: String(processEnv.STRATUSCLAW_CONFIG) }
|
|
2962
|
-
: undefined;
|
|
3199
|
+
: undefined;
|
|
2963
3200
|
const explicit = explicitSource?.value;
|
|
2964
3201
|
const candidates = explicitSource
|
|
2965
3202
|
? [{ path: path.resolve(cwd, explicitSource.value), label: explicitSource.name }]
|
|
2966
3203
|
: [
|
|
2967
3204
|
{ path: path.join(cwd, DEFAULT_CONFIG_FILENAME), label: 'project' },
|
|
2968
|
-
{ path: path.join(cwd, LEGACY_CONFIG_FILENAME), label: 'project (legacy)' },
|
|
2969
3205
|
{ path: globalConfigPath(env), label: 'global' },
|
|
2970
3206
|
];
|
|
2971
3207
|
const present = [];
|
|
@@ -3018,16 +3254,10 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
|
|
|
3018
3254
|
}
|
|
3019
3255
|
// Which variable supplied a value, not just the value: naming the wrong
|
|
3020
3256
|
// 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;
|
|
3257
|
+
// leaving the real override in place.
|
|
3258
|
+
const envPick = (name) => {
|
|
3259
|
+
const value = readNonEmptyString(processEnv[name]);
|
|
3260
|
+
return typeof value === 'string' ? { name, value } : undefined;
|
|
3031
3261
|
};
|
|
3032
3262
|
/**
|
|
3033
3263
|
* The verdict comes from the resolver, not from a second copy of its
|
|
@@ -3036,7 +3266,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
|
|
|
3036
3266
|
* while whether a run works at all, and what it bills, is whatever
|
|
3037
3267
|
* resolveRuntimeConfig actually returns or throws.
|
|
3038
3268
|
*/
|
|
3039
|
-
const envSoul = envPick('STRATUS_SOUL'
|
|
3269
|
+
const envSoul = envPick('STRATUS_SOUL');
|
|
3040
3270
|
const soulValue = envSoul?.value ?? fileConfig.soul;
|
|
3041
3271
|
const soulSource = envSoul ? envSoul.name : (winner ? winner.path : '');
|
|
3042
3272
|
const soulPath = typeof soulValue === 'string' ? path.resolve(cwd, soulValue) : undefined;
|
|
@@ -3059,7 +3289,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
|
|
|
3059
3289
|
}
|
|
3060
3290
|
}
|
|
3061
3291
|
}
|
|
3062
|
-
const envProviderPick = envPick('STRATUS_PROVIDER'
|
|
3292
|
+
const envProviderPick = envPick('STRATUS_PROVIDER');
|
|
3063
3293
|
const providerSource = envProviderPick
|
|
3064
3294
|
? envProviderPick.name
|
|
3065
3295
|
: soul?.provider
|
|
@@ -3089,7 +3319,7 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
|
|
|
3089
3319
|
}
|
|
3090
3320
|
let model;
|
|
3091
3321
|
if (resolved && resolved.provider !== 'demo') {
|
|
3092
|
-
const envModel = envPick('STRATUS_MODEL'
|
|
3322
|
+
const envModel = envPick('STRATUS_MODEL');
|
|
3093
3323
|
// A soul's model belongs to the soul's provider, and the config's model
|
|
3094
3324
|
// to the config's provider — either can be stranded by an override.
|
|
3095
3325
|
const soulModelApplies = soul?.provider === undefined || soul.provider === resolved.provider;
|
|
@@ -3442,8 +3672,7 @@ export const runService = async (command, streams, env = {}) => {
|
|
|
3442
3672
|
// install directory and can come up on a different roster entirely.
|
|
3443
3673
|
const processEnv = readProcessEnv(env);
|
|
3444
3674
|
const selectedConfig = command.configPath
|
|
3445
|
-
?? readNonEmptyString(processEnv.STRATUS_CONFIG)
|
|
3446
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_CONFIG);
|
|
3675
|
+
?? readNonEmptyString(processEnv.STRATUS_CONFIG);
|
|
3447
3676
|
if (command.action === 'install') {
|
|
3448
3677
|
// A config the daemon cannot parse kills it during gateway.start(),
|
|
3449
3678
|
// and the manager — having accepted the start — restarts it on a
|
|
@@ -3497,6 +3726,159 @@ export const runService = async (command, streams, env = {}) => {
|
|
|
3497
3726
|
}
|
|
3498
3727
|
return result.ok ? 0 : 1;
|
|
3499
3728
|
};
|
|
3729
|
+
/**
|
|
3730
|
+
* What a `skill add` source string means, in order: a directory on this
|
|
3731
|
+
* machine, a GitHub `owner/repo` shorthand, or a URL git can clone. The
|
|
3732
|
+
* shorthand is the form skills.sh and its CLI print, so a skill published
|
|
3733
|
+
* there installs by the name its listing shows.
|
|
3734
|
+
*/
|
|
3735
|
+
const resolveSkillSource = async (source, env) => {
|
|
3736
|
+
const localPath = path.resolve(readWorkingDirectory(env), source);
|
|
3737
|
+
try {
|
|
3738
|
+
if ((await stat(localPath)).isDirectory()) {
|
|
3739
|
+
return { kind: 'local', directory: localPath };
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
catch {
|
|
3743
|
+
// Not a local directory; read it as a remote source.
|
|
3744
|
+
}
|
|
3745
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(source)) {
|
|
3746
|
+
return { kind: 'git', url: `https://github.com/${source}` };
|
|
3747
|
+
}
|
|
3748
|
+
if (/^(https?|git|ssh|file):\/\//.test(source) || /^git@[\w.-]+:/.test(source)) {
|
|
3749
|
+
return { kind: 'git', url: source };
|
|
3750
|
+
}
|
|
3751
|
+
throw new Error(`Cannot read ${JSON.stringify(source)} as a skill source. Pass a GitHub owner/repo, a git URL, or a local path.`);
|
|
3752
|
+
};
|
|
3753
|
+
/** Shallow-clone a skills source. Git owns every transport we would otherwise re-implement. */
|
|
3754
|
+
const cloneSkillSource = async (url, destination) => {
|
|
3755
|
+
await new Promise((resolve, reject) => {
|
|
3756
|
+
const child = spawn('git', ['clone', '--depth', '1', '--quiet', url, destination], {
|
|
3757
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
3758
|
+
});
|
|
3759
|
+
let stderr = '';
|
|
3760
|
+
child.stderr?.on('data', (chunk) => {
|
|
3761
|
+
stderr += String(chunk);
|
|
3762
|
+
});
|
|
3763
|
+
child.on('error', (error) => {
|
|
3764
|
+
reject(new Error(`Could not run git to fetch ${url}: ${error.message}`));
|
|
3765
|
+
});
|
|
3766
|
+
child.on('close', (code) => {
|
|
3767
|
+
if (code === 0) {
|
|
3768
|
+
resolve();
|
|
3769
|
+
}
|
|
3770
|
+
else {
|
|
3771
|
+
reject(new Error(`git clone failed for ${url}: ${stderr.trim() || `exit code ${code}`}`));
|
|
3772
|
+
}
|
|
3773
|
+
});
|
|
3774
|
+
});
|
|
3775
|
+
};
|
|
3776
|
+
export const runSkillAdd = async (command, streams, env = {}) => {
|
|
3777
|
+
const resolved = await resolveSkillSource(command.source, env);
|
|
3778
|
+
let sourceDir;
|
|
3779
|
+
let cleanup;
|
|
3780
|
+
if (resolved.kind === 'local') {
|
|
3781
|
+
sourceDir = resolved.directory;
|
|
3782
|
+
}
|
|
3783
|
+
else {
|
|
3784
|
+
const scratch = await mkdtemp(path.join(os.tmpdir(), 'stratus-skill-add-'));
|
|
3785
|
+
cleanup = () => rm(scratch, { recursive: true, force: true });
|
|
3786
|
+
writeLine(streams.stdout, `Fetching ${resolved.url} …`);
|
|
3787
|
+
try {
|
|
3788
|
+
await cloneSkillSource(resolved.url, scratch);
|
|
3789
|
+
}
|
|
3790
|
+
catch (error) {
|
|
3791
|
+
await cleanup();
|
|
3792
|
+
throw error;
|
|
3793
|
+
}
|
|
3794
|
+
sourceDir = scratch;
|
|
3795
|
+
}
|
|
3796
|
+
try {
|
|
3797
|
+
const result = await installSkillsFromDirectory(env, sourceDir, {
|
|
3798
|
+
...(command.skillIds ? { only: command.skillIds } : {}),
|
|
3799
|
+
...(command.force ? { force: true } : {}),
|
|
3800
|
+
});
|
|
3801
|
+
for (const skill of result.installed) {
|
|
3802
|
+
writeLine(streams.stdout, `installed ${skill.id} — ${skill.description}`);
|
|
3803
|
+
}
|
|
3804
|
+
for (const skip of result.skipped) {
|
|
3805
|
+
writeLine(streams.stderr, `Warning: skipped ${skip.id}: ${skip.reason}`);
|
|
3806
|
+
}
|
|
3807
|
+
if (result.installed.length === 0) {
|
|
3808
|
+
writeLine(streams.stderr, result.skipped.length > 0
|
|
3809
|
+
? 'Error: nothing was installed.'
|
|
3810
|
+
: `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
|
|
3811
|
+
return 1;
|
|
3812
|
+
}
|
|
3813
|
+
// Installed is not enabled: a soul opts in through its skills:
|
|
3814
|
+
// allowlist, and that stays true when the install came through a
|
|
3815
|
+
// command. --agent is the explicit way to say both at once.
|
|
3816
|
+
const ids = result.installed.map((skill) => skill.id);
|
|
3817
|
+
if (command.agentId === undefined) {
|
|
3818
|
+
writeLine(streams.stdout);
|
|
3819
|
+
writeLine(streams.stdout, 'Installed, not yet enabled. Add to an agent\'s soul frontmatter:');
|
|
3820
|
+
writeLine(streams.stdout, ' skills:');
|
|
3821
|
+
for (const id of ids) {
|
|
3822
|
+
writeLine(streams.stdout, ` - ${id}`);
|
|
3823
|
+
}
|
|
3824
|
+
writeLine(streams.stdout, `(or rerun with --agent <id>, or list them: stratus skills)`);
|
|
3825
|
+
return 0;
|
|
3826
|
+
}
|
|
3827
|
+
const roster = await loadRosterSouls(env, (line) => writeLine(streams.stderr, `Warning: ${line}`));
|
|
3828
|
+
const entry = roster.find((candidate) => candidate.soul.agent.id === command.agentId);
|
|
3829
|
+
if (!entry) {
|
|
3830
|
+
writeLine(streams.stderr, `Error: no agent with id ${command.agentId} in ${agentsDirPath(env)}. The skills are installed; enable them by editing a soul.`);
|
|
3831
|
+
return 1;
|
|
3832
|
+
}
|
|
3833
|
+
const existing = entry.soul.agent.skills ?? [];
|
|
3834
|
+
const additions = ids.filter((id) => !matchesSkillAllowlist(id, existing));
|
|
3835
|
+
if (additions.length === 0) {
|
|
3836
|
+
writeLine(streams.stdout, `${entry.soul.agent.name} already has all of these enabled.`);
|
|
3837
|
+
return 0;
|
|
3838
|
+
}
|
|
3839
|
+
// A field edit renders through formatSoul, which canonicalizes the
|
|
3840
|
+
// file — same trade the control API's field edits make.
|
|
3841
|
+
const next = {
|
|
3842
|
+
...entry.soul,
|
|
3843
|
+
agent: { ...entry.soul.agent, skills: [...existing, ...additions] },
|
|
3844
|
+
};
|
|
3845
|
+
await writeFile(entry.path, formatSoul(next));
|
|
3846
|
+
writeLine(streams.stdout, `enabled for ${next.agent.name} (${entry.path}): ${additions.join(', ')}`);
|
|
3847
|
+
return 0;
|
|
3848
|
+
}
|
|
3849
|
+
finally {
|
|
3850
|
+
await cleanup?.();
|
|
3851
|
+
}
|
|
3852
|
+
};
|
|
3853
|
+
export const runSkills = async (streams, env = {}) => {
|
|
3854
|
+
const registry = new SkillRegistry();
|
|
3855
|
+
const skills = await loadOperatorSkills(env, registry, (line) => {
|
|
3856
|
+
writeLine(streams.stderr, `Warning: ${line}`);
|
|
3857
|
+
});
|
|
3858
|
+
if (skills.length === 0) {
|
|
3859
|
+
writeLine(streams.stdout, `No skills installed in ${skillsDirPath(env)}.`);
|
|
3860
|
+
writeLine(streams.stdout, 'Install some: stratus skill add <owner/repo | git url | path>');
|
|
3861
|
+
return 0;
|
|
3862
|
+
}
|
|
3863
|
+
// Who has each skill enabled, so the listing answers the question that
|
|
3864
|
+
// follows "what is installed" — read from the same roster a dispatch
|
|
3865
|
+
// serves. Plugin-contributed skills are the daemon's to list
|
|
3866
|
+
// (/catalog/tools); this command reads the operator directory.
|
|
3867
|
+
const roster = await loadRosterSouls(env, () => { }).catch(() => []);
|
|
3868
|
+
for (const skill of skills) {
|
|
3869
|
+
const enabledBy = roster
|
|
3870
|
+
.filter((entry) => {
|
|
3871
|
+
const allowlist = entry.soul.agent.skills;
|
|
3872
|
+
return allowlist !== undefined
|
|
3873
|
+
&& allowlist.length > 0
|
|
3874
|
+
&& matchesSkillAllowlist(skill.id, allowlist);
|
|
3875
|
+
})
|
|
3876
|
+
.map((entry) => entry.soul.agent.name);
|
|
3877
|
+
const suffix = enabledBy.length > 0 ? ` — enabled by ${enabledBy.join(', ')}` : ' — enabled by nobody yet';
|
|
3878
|
+
writeLine(streams.stdout, `${skill.id.padEnd(24)}${skill.description}${suffix}`);
|
|
3879
|
+
}
|
|
3880
|
+
return 0;
|
|
3881
|
+
};
|
|
3500
3882
|
export const runAgentNew = async (command, streams, env = {}) => {
|
|
3501
3883
|
// On a real terminal, creating an agent is the same guided experience as
|
|
3502
3884
|
// setup: a headed screen, a prefilled (editable) name, a personality, and
|
|
@@ -3533,14 +3915,12 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3533
3915
|
writeLine(streams.stdout, `Note: ${message}.`);
|
|
3534
3916
|
});
|
|
3535
3917
|
const soulProvider = readNonEmptyString(processEnv.STRATUS_PROVIDER, (value) => parseProviderName(value, 'STRATUS_PROVIDER'))
|
|
3536
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER, (value) => parseProviderName(value, 'STRATUSCLAW_PROVIDER'))
|
|
3537
3918
|
?? activeConfig.provider
|
|
3538
3919
|
?? 'demo';
|
|
3539
3920
|
// The active config's model was written for the provider named in
|
|
3540
3921
|
// that config; it only travels into the soul when they still match.
|
|
3541
3922
|
const configModelApplies = (activeConfig.provider ?? 'openai') === soulProvider;
|
|
3542
3923
|
const soulModel = readNonEmptyString(processEnv.STRATUS_MODEL)
|
|
3543
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_MODEL)
|
|
3544
3924
|
?? (configModelApplies ? activeConfig.model : undefined)
|
|
3545
3925
|
?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
|
|
3546
3926
|
const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
|
|
@@ -3787,6 +4167,31 @@ export const runDashboard = async (command, streams, env = {}) => {
|
|
|
3787
4167
|
}
|
|
3788
4168
|
return serving;
|
|
3789
4169
|
};
|
|
4170
|
+
/**
|
|
4171
|
+
* Resolution and loading are separate questions, and only the first one
|
|
4172
|
+
* means "not installed". Inspecting an import's error message cannot tell
|
|
4173
|
+
* them apart: a package that IS installed but is missing one of its own
|
|
4174
|
+
* dependencies throws ERR_MODULE_NOT_FOUND naming that dependency and the
|
|
4175
|
+
* importer — so a broken install would read as an absent one, silently
|
|
4176
|
+
* disabling a channel whose stored tokens say it should be running, or
|
|
4177
|
+
* leaving a daemon without the API an operator installed it to have.
|
|
4178
|
+
*/
|
|
4179
|
+
const defaultPackageResolver = (specifier) => {
|
|
4180
|
+
try {
|
|
4181
|
+
import.meta.resolve(specifier);
|
|
4182
|
+
return true;
|
|
4183
|
+
}
|
|
4184
|
+
catch {
|
|
4185
|
+
return false;
|
|
4186
|
+
}
|
|
4187
|
+
};
|
|
4188
|
+
/**
|
|
4189
|
+
* Whether an optional package is installed. The resolver is injectable
|
|
4190
|
+
* because what is installed is a property of the machine: a test asserting
|
|
4191
|
+
* on the real one would be asserting on its own node_modules, which is why
|
|
4192
|
+
* every optional package is a devDependency here in the first place.
|
|
4193
|
+
*/
|
|
4194
|
+
const packageInstalled = (specifier, env = {}) => (env.packageResolver ?? defaultPackageResolver)(specifier);
|
|
3790
4195
|
/**
|
|
3791
4196
|
* Loads the optional Slack channel package, or undefined when it is not
|
|
3792
4197
|
* installed. Only that package being absent is tolerated: an adapter that
|
|
@@ -3795,17 +4200,7 @@ export const runDashboard = async (command, streams, env = {}) => {
|
|
|
3795
4200
|
* whose stored tokens say it should be running.
|
|
3796
4201
|
*/
|
|
3797
4202
|
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 {
|
|
4203
|
+
if (!packageInstalled('@stratusagent/channel-slack')) {
|
|
3809
4204
|
return undefined;
|
|
3810
4205
|
}
|
|
3811
4206
|
// Resolvable: any failure from here is a real problem with the
|
|
@@ -3822,10 +4217,7 @@ const loadSlackAdapter = async () => {
|
|
|
3822
4217
|
* without the API an operator installed it to have.
|
|
3823
4218
|
*/
|
|
3824
4219
|
const loadControlApi = async () => {
|
|
3825
|
-
|
|
3826
|
-
import.meta.resolve('@stratusagent/control-api');
|
|
3827
|
-
}
|
|
3828
|
-
catch {
|
|
4220
|
+
if (!packageInstalled('@stratusagent/control-api')) {
|
|
3829
4221
|
return undefined;
|
|
3830
4222
|
}
|
|
3831
4223
|
return (await import('@stratusagent/control-api')).createControlApi;
|
|
@@ -4245,6 +4637,12 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
4245
4637
|
if (command.command === 'agents') {
|
|
4246
4638
|
return await runAgents(command, streams, resolvedEnv);
|
|
4247
4639
|
}
|
|
4640
|
+
if (command.command === 'skill-add') {
|
|
4641
|
+
return await runSkillAdd(command, streams, resolvedEnv);
|
|
4642
|
+
}
|
|
4643
|
+
if (command.command === 'skills') {
|
|
4644
|
+
return await runSkills(streams, resolvedEnv);
|
|
4645
|
+
}
|
|
4248
4646
|
if (command.command === 'doctor') {
|
|
4249
4647
|
return await runDoctor(command, streams, resolvedEnv);
|
|
4250
4648
|
}
|