@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/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, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
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
- export const CLI_VERSION = '0.6.0';
30
+ /**
31
+ * Whether spawning npm needs a shell on this platform.
32
+ *
33
+ * On Windows npm is `npm.cmd`, a batch file: a bare `npm` does not exist as
34
+ * an executable there (PATHEXT is a shell's job, not spawn's), and naming
35
+ * the `.cmd` directly has thrown EINVAL since the fix for CVE-2024-27980 —
36
+ * both land in the caller's error handler, so a Windows setup would report
37
+ * that it could not install and leave Slack and the dashboard missing.
38
+ *
39
+ * Every package name reaching the shell is a constant in this file, so
40
+ * there is nothing user-supplied here for it to re-parse.
41
+ */
42
+ export const npmNeedsShell = (platform) => platform === 'win32';
43
+ const defaultPackageInstaller = async (packages) => {
44
+ const { spawn } = await import('node:child_process');
45
+ return new Promise((resolve) => {
46
+ // npm's own output is inherited rather than captured: a global install
47
+ // runs for tens of seconds, and a silent one is indistinguishable from
48
+ // a setup that has hung. stdin is NOT inherited — the setup prompter
49
+ // owns it, and two readers on one stream race for the same bytes.
50
+ const child = spawn('npm', ['install', '-g', ...packages], {
51
+ stdio: ['ignore', 'inherit', 'inherit'],
52
+ shell: npmNeedsShell(process.platform),
53
+ });
54
+ // `error` and `close` can both fire — a spawn that fails still closes.
55
+ let settled = false;
56
+ const finish = (result) => {
57
+ if (settled) {
58
+ return;
59
+ }
60
+ settled = true;
61
+ resolve(result);
62
+ };
63
+ child.once('error', (error) => {
64
+ finish({ ok: false, message: error.message });
65
+ });
66
+ child.once('close', (code) => {
67
+ finish(code === 0
68
+ ? { ok: true, message: '' }
69
+ : { ok: false, message: `npm exited with code ${code ?? 'unknown'}` });
70
+ });
71
+ });
72
+ };
73
+ export const CLI_VERSION = '0.7.0';
30
74
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
31
75
  /**
32
76
  * The range every package declares in its `engines` field. Two floors, not
@@ -80,6 +124,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 or the legacy
828
- // STRATUSCLAW_ prefix supplied the key, sending the reader to unset
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 / STRATUSCLAW_CONFIG, then
1672
- // the global ~/.stratus/config.json — the file `stratus run` falls back to
1673
- // from any directory, which is what makes setup a one-time step.
1674
- const envConfigVar = readNonEmptyString(processEnv.STRATUS_CONFIG)
1675
- ? 'STRATUS_CONFIG'
1676
- : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
1677
- ? 'STRATUSCLAW_CONFIG'
1678
- : undefined;
1679
- const envConfigPath = envConfigVar ? String(processEnv[envConfigVar]).trim() : undefined;
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, legacy, chosen, flagName) => {
2599
- const envVar = readNonEmptyString(processEnv[primary])
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[envVar]).trim();
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
- for (const shadow of [DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME]) {
2654
- const shadowPath = path.join(cwd, shadow);
2655
- try {
2656
- await readFile(shadowPath, 'utf8');
2657
- writeLine(streams.stdout, `Note: ${shadowPath} exists and takes precedence over the global config for runs started in this directory.`);
2658
- writeLine(streams.stdout, 'The suggested commands below include --config so they use what you just saved.');
2659
- shadowConfigFlag = ` --config ${quoteShellArg(configPath)}`;
2660
- break;
2661
- }
2662
- catch (error) {
2663
- if (error.code !== 'ENOENT') {
2664
- throw error;
2665
- }
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', 'STRATUSCLAW_PROVIDER', state.provider, '--provider'),
2974
+ detectEnvOverride('STRATUS_PROVIDER', state.provider, '--provider'),
2738
2975
  ...(state.provider !== 'demo'
2739
- ? [detectEnvOverride('STRATUS_MODEL', 'STRATUSCLAW_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
2976
+ ? [detectEnvOverride('STRATUS_MODEL', state.model ?? defaultModelFor(state.provider), '--model')]
2740
2977
  : []),
2741
2978
  ...(state.provider === 'openai'
2742
- ? [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', state.baseUrl ?? DEFAULT_OPENAI_BASE_URL, '--base-url')]
2743
- : [detectEnvOverride('STRATUS_BASE_URL', 'STRATUSCLAW_BASE_URL', '')]),
2744
- detectEnvOverride('STRATUS_SYSTEM_PROMPT', 'STRATUSCLAW_SYSTEM_PROMPT', state.systemPrompt ?? ''),
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
- writeLine(streams.stdout, ' stratus dashboard');
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 three named it, not just that one did: telling someone to
2953
- // fix STRATUS_CONFIG when STRATUSCLAW_CONFIG is what is set leaves the
2954
- // real override in place — the same mistake as provider and key
2955
- // attribution, in the one place a typo is most likely.
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
- : readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
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. The legacy STRATUSCLAW_ prefix is
3022
- // still honored by the resolver, so it has to be reportable too.
3023
- const envPick = (...names) => {
3024
- for (const name of names) {
3025
- const value = readNonEmptyString(processEnv[name]);
3026
- if (typeof value === 'string') {
3027
- return { name, value };
3028
- }
3029
- }
3030
- return undefined;
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', 'STRATUSCLAW_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', 'STRATUSCLAW_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', 'STRATUSCLAW_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
- // Resolution and loading are separate questions, and only the first one
3799
- // means "not installed". Inspecting the import's error message cannot
3800
- // tell them apart: a package that IS installed but is missing one of
3801
- // its own dependencies throws ERR_MODULE_NOT_FOUND naming that
3802
- // dependency and this package as the importer — a broken install would
3803
- // read as an absent one, silently disabling a channel whose tokens say
3804
- // it should be running.
3805
- try {
3806
- import.meta.resolve('@stratusagent/channel-slack');
3807
- }
3808
- catch {
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
- try {
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
  }