@stratusagent/cli 0.11.5 → 0.11.7

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.
Files changed (80) hide show
  1. package/README.md +8 -7
  2. package/dist/approvals.d.ts.map +1 -1
  3. package/dist/approvals.js +30 -1
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/commands/channel.d.ts +17 -0
  6. package/dist/commands/channel.d.ts.map +1 -0
  7. package/dist/commands/channel.js +115 -0
  8. package/dist/commands/channel.js.map +1 -0
  9. package/dist/commands/chat.js +3 -3
  10. package/dist/commands/chat.js.map +1 -1
  11. package/dist/commands/doctor.d.ts.map +1 -1
  12. package/dist/commands/doctor.js +80 -2
  13. package/dist/commands/doctor.js.map +1 -1
  14. package/dist/commands/grants.d.ts.map +1 -1
  15. package/dist/commands/grants.js +33 -9
  16. package/dist/commands/grants.js.map +1 -1
  17. package/dist/commands/schedules.d.ts.map +1 -1
  18. package/dist/commands/schedules.js +7 -3
  19. package/dist/commands/schedules.js.map +1 -1
  20. package/dist/commands/serve.d.ts.map +1 -1
  21. package/dist/commands/serve.js +73 -7
  22. package/dist/commands/serve.js.map +1 -1
  23. package/dist/commands/service.d.ts +13 -0
  24. package/dist/commands/service.d.ts.map +1 -1
  25. package/dist/commands/service.js +36 -2
  26. package/dist/commands/service.js.map +1 -1
  27. package/dist/commands/setup.d.ts.map +1 -1
  28. package/dist/commands/setup.js +200 -15
  29. package/dist/commands/setup.js.map +1 -1
  30. package/dist/commands/skills.d.ts +1 -1
  31. package/dist/commands/skills.d.ts.map +1 -1
  32. package/dist/commands/skills.js +14 -3
  33. package/dist/commands/skills.js.map +1 -1
  34. package/dist/commands/template.d.ts.map +1 -1
  35. package/dist/commands/template.js +4 -1
  36. package/dist/commands/template.js.map +1 -1
  37. package/dist/commands/update.d.ts.map +1 -1
  38. package/dist/commands/update.js +25 -1
  39. package/dist/commands/update.js.map +1 -1
  40. package/dist/environment.d.ts +2 -0
  41. package/dist/environment.d.ts.map +1 -1
  42. package/dist/events.d.ts.map +1 -1
  43. package/dist/events.js +20 -0
  44. package/dist/events.js.map +1 -1
  45. package/dist/help.d.ts +1 -1
  46. package/dist/help.d.ts.map +1 -1
  47. package/dist/help.js +31 -17
  48. package/dist/help.js.map +1 -1
  49. package/dist/loaders.d.ts +11 -0
  50. package/dist/loaders.d.ts.map +1 -1
  51. package/dist/loaders.js +8 -0
  52. package/dist/loaders.js.map +1 -1
  53. package/dist/main.d.ts.map +1 -1
  54. package/dist/main.js +5 -0
  55. package/dist/main.js.map +1 -1
  56. package/dist/npm.d.ts +1 -1
  57. package/dist/npm.js +1 -1
  58. package/dist/parse.d.ts +16 -11
  59. package/dist/parse.d.ts.map +1 -1
  60. package/dist/parse.js +72 -10
  61. package/dist/parse.js.map +1 -1
  62. package/dist/prompter.d.ts +2 -2
  63. package/dist/prompter.d.ts.map +1 -1
  64. package/dist/prompter.js +4 -3
  65. package/dist/prompter.js.map +1 -1
  66. package/dist/runtime.d.ts +2 -1
  67. package/dist/runtime.d.ts.map +1 -1
  68. package/dist/runtime.js +26 -4
  69. package/dist/runtime.js.map +1 -1
  70. package/dist/service.d.ts.map +1 -1
  71. package/dist/service.js +2 -0
  72. package/dist/service.js.map +1 -1
  73. package/dist/slack.d.ts.map +1 -1
  74. package/dist/slack.js +9 -0
  75. package/dist/slack.js.map +1 -1
  76. package/dist/supervisor.d.ts +9 -0
  77. package/dist/supervisor.d.ts.map +1 -1
  78. package/dist/supervisor.js +9 -0
  79. package/dist/supervisor.js.map +1 -1
  80. package/package.json +29 -26
@@ -4,7 +4,7 @@ import { preflightPlugin, readPluginManifest, PluginConfigError, PluginManifestE
4
4
  import { DEFAULT_ANTHROPIC_MODEL } from '@stratusagent/provider-anthropic';
5
5
  import { DEFAULT_CODEX_MODEL } from '@stratusagent/provider-codex';
6
6
  import { formatSoul, parseSoul } from '@stratusagent/agents';
7
- import { claimSoulFile, collectAvailableModels as collectModels, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, globalConfigPath, loadChannelCredentials, loadCredentials, loadRosterSouls, loadSoulFile, isRegisteredProviderName, readNonEmptyString, registeredProviderNameOf, readProcessEnv, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, saveChannelCredentials, saveConfigFile, saveCredentials, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, } from '@stratusagent/state';
7
+ import { claimSoulFile, collectAvailableModels as collectModels, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, globalConfigPath, loadChannelCredentials, loadChannelTransportSecrets, loadCredentials, loadRosterSouls, loadSoulFile, isRegisteredProviderName, languageFor, readNonEmptyString, registeredProviderNameOf, readProcessEnv, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, saveChannelCredentials, saveChannelTransportSecrets, removeChannelTransportSecrets, CREDENTIAL_NAME_PATTERN, saveConfigFile, saveCredentials, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, } from '@stratusagent/state';
8
8
  import { installService, readServiceStatus, servicePlatform, serviceUnitPath, uninstallService, } from "../service.js";
9
9
  import { classifyApprovalReach, unattendedReachParts } from "../approvals.js";
10
10
  import { serviceEnvFor } from "../daemon.js";
@@ -97,6 +97,7 @@ export const runSetup = async (command, streams, env = {}) => {
97
97
  ...(existing.vision !== undefined ? { vision: existing.vision } : {}),
98
98
  ...(existing.promptCache !== undefined ? { promptCache: existing.promptCache } : {}),
99
99
  ...(existing.promptCacheTtl !== undefined ? { promptCacheTtl: existing.promptCacheTtl } : {}),
100
+ ...(existing.language !== undefined ? { language: existing.language } : {}),
100
101
  ...(existing.plugins !== undefined ? { plugins: existing.plugins } : {}),
101
102
  ...(existing.approvals !== undefined ? { approvals: existing.approvals } : {}),
102
103
  ...(existing.api !== undefined ? { api: existing.api } : {}),
@@ -116,6 +117,8 @@ export const runSetup = async (command, streams, env = {}) => {
116
117
  credentialsDirty: false,
117
118
  channels: await loadChannelCredentials(env),
118
119
  channelsDirty: false,
120
+ pluginChannelSecrets: {},
121
+ pluginChannelEdits: [],
119
122
  // On by default: setup's whole promise is that you finish it and the
120
123
  // agents are working. An always-on runtime you have to remember to
121
124
  // start is not always-on, and every Slack app configured here stays
@@ -221,12 +224,12 @@ export const runSetup = async (command, streams, env = {}) => {
221
224
  }
222
225
  return state.soulPath;
223
226
  };
224
- const channelsSummary = () => {
227
+ const channelsSummary = (pluginKinds) => {
225
228
  const connected = Object.keys(state.channels.slack ?? {}).length;
226
- if (connected === 0) {
227
- return 'Slack: not connected';
228
- }
229
- return `Slack: ${connected} agent${connected === 1 ? '' : 's'} connected`;
229
+ const slack = connected === 0
230
+ ? 'Slack: not connected'
231
+ : `Slack: ${connected} agent${connected === 1 ? '' : 's'} connected`;
232
+ return pluginKinds.length === 0 ? slack : `${slack}; also ${pluginKinds.join(', ')}`;
230
233
  };
231
234
  const storeCredential = (provider, credential) => {
232
235
  state.credentials[provider] = credential;
@@ -705,7 +708,15 @@ export const runSetup = async (command, streams, env = {}) => {
705
708
  ...(codexSubscription ? { codexSubscription: true } : {}),
706
709
  };
707
710
  };
711
+ // The language the saved config would run in, soul over config, carried
712
+ // onto whichever variant the test run builds: it is part of what the
713
+ // quick hello is there to exercise.
708
714
  const buildTestRuntime = async () => {
715
+ const runtime = await buildTestRuntimeVariant();
716
+ const language = runtime ? languageFor(runtime.soul, state) : undefined;
717
+ return runtime && language !== undefined ? { ...runtime, language } : runtime;
718
+ };
719
+ const buildTestRuntimeVariant = async () => {
709
720
  let soul;
710
721
  if (state.soulPath) {
711
722
  try {
@@ -822,27 +833,31 @@ export const runSetup = async (command, streams, env = {}) => {
822
833
  writeLine(streams.stdout);
823
834
  writeLine(streams.stdout, `Create a Slack app for ${agentName}:`);
824
835
  writeLine(streams.stdout, ' 1. Open https://api.slack.com/apps → Create New App → From a manifest');
825
- writeLine(streams.stdout, ' 2. Pick your workspace, then paste this manifest:');
836
+ writeLine(streams.stdout, ' 2. Pick your workspace → Next, then paste this manifest:');
826
837
  writeLine(streams.stdout);
827
838
  for (const line of slackAppManifest(agentName).split('\n')) {
828
839
  writeLine(streams.stdout, ` ${line}`);
829
840
  }
830
841
  writeLine(streams.stdout);
831
- writeLine(streams.stdout, ' 3. Create the app, then Basic Information → App-Level Tokens →');
832
- writeLine(streams.stdout, ' Generate a token with the connections:write scope (xapp-…)');
833
- writeLine(streams.stdout, ' 4. Install App → copy the Bot User OAuth Token (xoxb-…)');
842
+ // The app-level token is the step people got lost on: it lives on
843
+ // Basic Information rather than OAuth & Permissions, where the other
844
+ // token is, and needs a scope added in the dialog that generates it.
845
+ writeLine(streams.stdout, ' 3. Click Next, then Create. You land on Basic Information — scroll down to');
846
+ writeLine(streams.stdout, ' App-Level Tokens and click Generate Token and Scopes (scope: connections:write)');
847
+ writeLine(streams.stdout, ' 4. Sidebar → Install App → Install to your workspace → Allow, then copy');
848
+ writeLine(streams.stdout, ' the Bot User OAuth Token — it starts with xoxb-');
834
849
  writeLine(streams.stdout, ` 5. Basic Information → Display Information → upload ${agentName}'s avatar`);
835
850
  writeLine(streams.stdout);
836
- const appToken = await prompter.askSecret('Paste the app-level token (xapp-…, Enter to cancel; input is hidden): ');
851
+ const appToken = await prompter.askSecret('Paste the app-level token from step 3 (xapp-…, Enter to cancel; input is hidden): ');
837
852
  if (!appToken) {
838
853
  writeLine(streams.stdout, 'Cancelled — nothing was saved.');
839
854
  return;
840
855
  }
841
856
  if (!appToken.startsWith('xapp-')) {
842
- writeLine(streams.stdout, '✗ That does not look like an app-level token (they start with xapp-). Nothing was saved.');
857
+ writeLine(streams.stdout, '✗ That does not look like an app-level token (they start with xapp-, and live under Basic Information → App-Level Tokens, not OAuth & Permissions). Nothing was saved.');
843
858
  return;
844
859
  }
845
- const botToken = await prompter.askSecret('Paste the bot user OAuth token (xoxb-…, Enter to cancel; input is hidden): ');
860
+ const botToken = await prompter.askSecret('Paste the Bot User OAuth Token from step 4 (xoxb-…, Enter to cancel; input is hidden): ');
846
861
  if (!botToken) {
847
862
  writeLine(streams.stdout, 'Cancelled — nothing was saved.');
848
863
  return;
@@ -860,7 +875,7 @@ export const runSetup = async (command, streams, env = {}) => {
860
875
  }
861
876
  const app = await verifySlackAppToken(appToken, fetchImpl);
862
877
  if (app.status === 'rejected') {
863
- writeLine(streams.stdout, `✗ Slack rejected the app-level token (${app.detail}). Nothing was saved — check it has the connections:write scope.`);
878
+ writeLine(streams.stdout, `✗ Slack rejected the app-level token (${app.detail}). Nothing was saved — generate a new one under Basic Information → App-Level Tokens with the connections:write scope.`);
864
879
  return;
865
880
  }
866
881
  const slack = { ...(state.channels.slack ?? {}) };
@@ -1162,6 +1177,7 @@ export const runSetup = async (command, streams, env = {}) => {
1162
1177
  // The qualified form the loader stages them under, which is what a
1163
1178
  // `skills:` entry has to match.
1164
1179
  skills: manifest.contributes.skills.map((skill) => `${manifest.packageName}:${skill.id}`),
1180
+ channels: manifest.contributes.channels.map((channel) => channel.name),
1165
1181
  };
1166
1182
  }
1167
1183
  catch {
@@ -1932,7 +1948,159 @@ export const runSetup = async (command, streams, env = {}) => {
1932
1948
  state.approvals = { ...(state.approvals ?? {}), mode: 'remote', agents };
1933
1949
  }
1934
1950
  };
1951
+ /**
1952
+ * The channel kinds the enabled plugins declare, Slack's own aside, each
1953
+ * with the config key of every plugin declaring it — from manifests,
1954
+ * never by loading a package, as the Plugins row reads them. Every one,
1955
+ * not the first: two plugins may carry the same kind for different agents
1956
+ * (channels key on agent and kind), and an agent bound by the second one's
1957
+ * config is as bound as one bound by the first's.
1958
+ */
1959
+ const pluginChannelKinds = async () => {
1960
+ const kinds = [];
1961
+ for (const name of pluginPackages()) {
1962
+ if (!pluginEnabled(name)) {
1963
+ continue;
1964
+ }
1965
+ for (const kind of (await pluginContributions(name))?.channels ?? []) {
1966
+ if (kind === 'slack') {
1967
+ continue;
1968
+ }
1969
+ const entry = kinds.find((candidate) => candidate.kind === kind);
1970
+ if (entry === undefined) {
1971
+ kinds.push({ kind, plugins: [name] });
1972
+ }
1973
+ else if (!entry.plugins.includes(name)) {
1974
+ entry.plugins.push(name);
1975
+ }
1976
+ }
1977
+ }
1978
+ return kinds;
1979
+ };
1980
+ /**
1981
+ * Slack alone, as it always was, until a plugin declares a channel: then
1982
+ * the row asks which channel first. A setup with no channel plugin sees
1983
+ * exactly the screens it saw before there could be one.
1984
+ */
1935
1985
  const chooseChannels = async () => {
1986
+ const kinds = await pluginChannelKinds();
1987
+ if (kinds.length === 0) {
1988
+ await chooseSlackChannel();
1989
+ return;
1990
+ }
1991
+ while (true) {
1992
+ const options = [
1993
+ 'Slack'.padEnd(20) + 'built in',
1994
+ ...kinds.map(({ kind, plugins }) => kind.padEnd(20) + `from ${plugins.join(', ')}`),
1995
+ 'Back',
1996
+ ];
1997
+ const choice = await prompter.select('Channels', options);
1998
+ if (choice.kind !== 'index' || choice.index === options.length - 1) {
1999
+ return;
2000
+ }
2001
+ const picked = kinds[choice.index - 1];
2002
+ if (picked === undefined) {
2003
+ await chooseSlackChannel();
2004
+ }
2005
+ else {
2006
+ await choosePluginChannel(picked.kind, picked.plugins);
2007
+ }
2008
+ }
2009
+ };
2010
+ /**
2011
+ * One plugin channel's agents, and whether each is bound — by secrets
2012
+ * stored under `channels.<kind>.<agentId>`, or by an entry under the
2013
+ * plugin's own config `agents` block. Either is a binding: a channel that
2014
+ * needs no secret (iMessage on this Mac) is bound by config alone, so a
2015
+ * menu that knew only secrets would call it not connected.
2016
+ *
2017
+ * Setup stores secrets and never writes the plugin's config: which keys
2018
+ * that block takes is the plugin's schema, which only its README states.
2019
+ */
2020
+ const choosePluginChannel = async (kind, plugins) => {
2021
+ const secretsFor = async () => {
2022
+ state.pluginChannelSecrets[kind] ??= await loadChannelTransportSecrets(env, kind);
2023
+ return state.pluginChannelSecrets[kind];
2024
+ };
2025
+ while (true) {
2026
+ const { entries: roster, loaded: rosterLoaded } = await channelRoster();
2027
+ const stored = await secretsFor();
2028
+ // Any plugin carrying this kind binds the agents its own block lists.
2029
+ const inConfig = (id) => plugins.some((plugin) => {
2030
+ const configured = state.plugins?.[plugin]?.agents;
2031
+ return typeof configured === 'object' && configured !== null && !Array.isArray(configured) && Object.hasOwn(configured, id);
2032
+ });
2033
+ const options = roster.map((entry) => {
2034
+ const id = entry.soul.agent.id;
2035
+ const bindings = [
2036
+ ...(stored[id] !== undefined ? [`✓ secrets: ${Object.keys(stored[id]).sort().join(', ')}`] : []),
2037
+ ...(inConfig(id) ? ['✓ in its plugin config'] : []),
2038
+ ];
2039
+ return `${entry.soul.agent.name} (${id})`.padEnd(34) + (bindings.length > 0 ? bindings.join('; ') : '— not connected');
2040
+ });
2041
+ // Only against a roster that loaded, for the reason the Slack menu
2042
+ // gives: an unreadable roster would make every binding look orphaned.
2043
+ const orphans = rosterLoaded
2044
+ ? Object.keys(stored).filter((id) => !roster.some((entry) => entry.soul.agent.id === id))
2045
+ : [];
2046
+ for (const id of orphans) {
2047
+ options.push(`${id}`.padEnd(34) + '! secrets without a matching agent');
2048
+ }
2049
+ options.push('Back');
2050
+ const blocks = plugins.map((plugin) => `plugins["${plugin}"].agents`).join(' or ');
2051
+ const choice = await prompter.select(`Channels — ${kind} (from ${plugins.join(', ')})`, options, {
2052
+ footnote: `Bound by secrets stored here, or by an entry under ${blocks} in your config — the plugin's README says which. Run \`${serveCommand()}\` afterwards.`,
2053
+ });
2054
+ if (choice.kind !== 'index' || choice.index === options.length - 1) {
2055
+ return;
2056
+ }
2057
+ const agentId = choice.index < roster.length
2058
+ ? roster[choice.index]?.soul.agent.id
2059
+ : orphans[choice.index - roster.length];
2060
+ if (agentId === undefined) {
2061
+ return;
2062
+ }
2063
+ const actions = [
2064
+ ...(choice.index < roster.length ? [stored[agentId] !== undefined ? 'Replace the stored secrets' : 'Store secrets'] : []),
2065
+ ...(stored[agentId] !== undefined ? ['Remove the stored secrets'] : []),
2066
+ 'Back',
2067
+ ];
2068
+ const action = await prompter.select(`${agentId} on ${kind}`, actions);
2069
+ if (action.kind !== 'index' || actions[action.index] === 'Back') {
2070
+ continue;
2071
+ }
2072
+ if (actions[action.index] === 'Remove the stored secrets') {
2073
+ delete stored[agentId];
2074
+ state.pluginChannelEdits.push({ kind, agentId });
2075
+ writeLine(streams.stdout, `Removed ${agentId}'s ${kind} secrets.`);
2076
+ continue;
2077
+ }
2078
+ const names = (await prompter.ask(`Secret names for ${kind}, separated by spaces (its README lists them): `))
2079
+ .split(/\s+/)
2080
+ .filter((name) => name.length > 0);
2081
+ if (names.length === 0) {
2082
+ continue;
2083
+ }
2084
+ const bad = names.find((name, index) => !CREDENTIAL_NAME_PATTERN.test(name) || names.indexOf(name) !== index);
2085
+ if (bad !== undefined) {
2086
+ writeLine(streams.stderr, `${bad} is not a secret name, or is named twice. Nothing was stored.`);
2087
+ continue;
2088
+ }
2089
+ const secrets = {};
2090
+ for (const name of names) {
2091
+ secrets[name] = await prompter.askSecret(`${name} (not echoed): `);
2092
+ }
2093
+ const empty = names.find((name) => (secrets[name] ?? '').length === 0);
2094
+ if (empty !== undefined) {
2095
+ writeLine(streams.stderr, `${empty} was empty, so nothing was stored. A binding missing a secret would never come online.`);
2096
+ continue;
2097
+ }
2098
+ stored[agentId] = secrets;
2099
+ state.pluginChannelEdits.push({ kind, agentId, secrets });
2100
+ writeLine(streams.stdout, `Stored ${names.join(', ')} for ${agentId} on ${kind}; saved when you finish.`);
2101
+ }
2102
+ };
2103
+ const chooseSlackChannel = async () => {
1936
2104
  while (true) {
1937
2105
  const { entries: roster, loaded: rosterLoaded } = await channelRoster();
1938
2106
  const slack = state.channels.slack ?? {};
@@ -2226,6 +2394,9 @@ export const runSetup = async (command, streams, env = {}) => {
2226
2394
  if (state.promptCacheTtl !== undefined) {
2227
2395
  config.promptCacheTtl = state.promptCacheTtl;
2228
2396
  }
2397
+ if (state.language !== undefined) {
2398
+ config.language = state.language;
2399
+ }
2229
2400
  // `plugins` and `approvals` have menus above; `api`, `principals`, and
2230
2401
  // `slack` do not and are written back exactly as they were read. Both cases
2231
2402
  // land here the same way, because the menus edit this state rather
@@ -2290,6 +2461,19 @@ export const runSetup = async (command, streams, env = {}) => {
2290
2461
  ? `Saved Slack tokens for ${connected} agent${connected === 1 ? '' : 's'} to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`
2291
2462
  : `Removed the stored Slack tokens from ${credentialsPath(env)}.`);
2292
2463
  }
2464
+ if (state.pluginChannelEdits.length > 0) {
2465
+ // In the order they were made, so a store then a remove of the same
2466
+ // binding ends removed, as the menu last showed it.
2467
+ for (const edit of state.pluginChannelEdits) {
2468
+ if (edit.secrets !== undefined) {
2469
+ await saveChannelTransportSecrets(env, edit.kind, edit.agentId, edit.secrets);
2470
+ }
2471
+ else {
2472
+ await removeChannelTransportSecrets(env, edit.kind, edit.agentId);
2473
+ }
2474
+ }
2475
+ writeLine(streams.stdout, `Saved your channel secrets to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`);
2476
+ }
2293
2477
  // Before the service block below, deliberately. A package installed
2294
2478
  // after the daemon starts is invisible to it — installing does not
2295
2479
  // reload a running process — so offering here is what makes the
@@ -2427,12 +2611,13 @@ export const runSetup = async (command, streams, env = {}) => {
2427
2611
  // corrupts every redraw after the first arrow key. Wrapping is not
2428
2612
  // the cosmetic cost I took it for when I let this line grow.
2429
2613
  const approvals = fitMenuRow((await approvalsSummary()).join('; '));
2614
+ const channelKinds = (await pluginChannelKinds()).map((entry) => entry.kind);
2430
2615
  const choice = await prompter.select('', [
2431
2616
  `Providers ${providersSummary()}`,
2432
2617
  `Models ${modelsSummary()}`,
2433
2618
  `Agent ${agentSummary()}`,
2434
2619
  `Plugins ${fitMenuRow(pluginsSummary())}`,
2435
- `Channels ${channelsSummary()}`,
2620
+ `Channels ${fitMenuRow(channelsSummary(channelKinds))}`,
2436
2621
  `Approvals ${approvals}`,
2437
2622
  `Always on ${serviceSummary()}`,
2438
2623
  'Test run say hello with the current settings',