@robota-sdk/agent-command 3.0.0-beta.100

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 (251) hide show
  1. package/CHANGELOG.md +1351 -0
  2. package/LICENSE +661 -0
  3. package/README.md +100 -0
  4. package/dist/node/index.cjs +121 -0
  5. package/dist/node/index.d.cts +1053 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1053 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +122 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +83 -0
  12. package/src/advisor/advisor-command-module.ts +47 -0
  13. package/src/advisor/advisor-command.test.ts +102 -0
  14. package/src/advisor/advisor-command.ts +55 -0
  15. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  16. package/src/advisor/index.ts +6 -0
  17. package/src/agent/__tests__/agent-command.test.ts +597 -0
  18. package/src/agent/agent-command-module.ts +89 -0
  19. package/src/agent/agent-command-parser.ts +180 -0
  20. package/src/agent/agent-command.ts +255 -0
  21. package/src/agent/index.ts +7 -0
  22. package/src/background/__tests__/background-command-module.test.ts +261 -0
  23. package/src/background/background-command-module.ts +52 -0
  24. package/src/background/background-command.ts +65 -0
  25. package/src/background/index.ts +6 -0
  26. package/src/command-module-utils.test.ts +37 -0
  27. package/src/command-module-utils.ts +35 -0
  28. package/src/compact/__tests__/compact-command-module.test.ts +181 -0
  29. package/src/compact/compact-command-module.ts +59 -0
  30. package/src/compact/compact-command.ts +33 -0
  31. package/src/compact/index.ts +6 -0
  32. package/src/context/__tests__/context-command-module.test.ts +502 -0
  33. package/src/context/auto-compact-format.ts +25 -0
  34. package/src/context/context-breakdown.ts +295 -0
  35. package/src/context/context-command-module.ts +82 -0
  36. package/src/context/context-command.ts +291 -0
  37. package/src/context/index.ts +6 -0
  38. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
  39. package/src/default/__tests__/default-command-modules.test.ts +192 -0
  40. package/src/default/__tests__/model-exposure.test.ts +269 -0
  41. package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
  42. package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
  43. package/src/default/default-command-modules.ts +247 -0
  44. package/src/default/index.ts +5 -0
  45. package/src/devices/__tests__/devices-command-module.test.ts +299 -0
  46. package/src/devices/devices-command-module.ts +308 -0
  47. package/src/devices/devices-command-port.ts +143 -0
  48. package/src/devices/index.ts +14 -0
  49. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  50. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  51. package/src/doctor/__tests__/doctor-fixture.ts +146 -0
  52. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  53. package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
  54. package/src/doctor/doctor-command-module.ts +132 -0
  55. package/src/doctor/doctor-extensions-probe.ts +323 -0
  56. package/src/doctor/doctor-node-deps.ts +88 -0
  57. package/src/doctor/doctor-redaction.ts +99 -0
  58. package/src/doctor/doctor-render.ts +70 -0
  59. package/src/doctor/doctor-repair.ts +136 -0
  60. package/src/doctor/doctor-runner.ts +122 -0
  61. package/src/doctor/doctor-settings-probe.ts +239 -0
  62. package/src/doctor/doctor-storage-probe.ts +187 -0
  63. package/src/doctor/doctor-types.ts +109 -0
  64. package/src/doctor/index.ts +32 -0
  65. package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
  66. package/src/editor/editor-command-module.ts +53 -0
  67. package/src/editor/editor-command.ts +58 -0
  68. package/src/editor/index.ts +7 -0
  69. package/src/editor/resolve-editor.ts +21 -0
  70. package/src/effort/effort-command-module.ts +51 -0
  71. package/src/effort/effort-command.test.ts +203 -0
  72. package/src/effort/effort-command.ts +136 -0
  73. package/src/effort/index.ts +6 -0
  74. package/src/events/__tests__/events-command.test.ts +73 -0
  75. package/src/events/events-command-module.ts +49 -0
  76. package/src/events/events-command.ts +69 -0
  77. package/src/events/index.ts +2 -0
  78. package/src/exit/__tests__/exit-command-module.test.ts +61 -0
  79. package/src/exit/exit-command-module.ts +47 -0
  80. package/src/exit/exit-command.ts +26 -0
  81. package/src/exit/index.ts +6 -0
  82. package/src/fork/__tests__/fork-command.test.ts +184 -0
  83. package/src/fork/fork-command-module.ts +61 -0
  84. package/src/fork/fork-command.ts +162 -0
  85. package/src/fork/index.ts +8 -0
  86. package/src/git/__tests__/fake-git-port.ts +35 -0
  87. package/src/git/__tests__/git-command-module.test.ts +111 -0
  88. package/src/git/__tests__/git-commit.test.ts +185 -0
  89. package/src/git/git-command-module.ts +138 -0
  90. package/src/git/git-commit.ts +254 -0
  91. package/src/git/index.ts +20 -0
  92. package/src/goal/__tests__/goal-command.test.ts +80 -0
  93. package/src/goal/goal-command-module.ts +49 -0
  94. package/src/goal/goal-command.ts +69 -0
  95. package/src/goal/index.ts +6 -0
  96. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  97. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  98. package/src/handoff/handoff-command-module.ts +49 -0
  99. package/src/handoff/handoff-command.ts +187 -0
  100. package/src/handoff/index.ts +6 -0
  101. package/src/help/__tests__/help-command-module.test.ts +134 -0
  102. package/src/help/__tests__/help-command.test.ts +130 -0
  103. package/src/help/help-command-module.ts +47 -0
  104. package/src/help/help-command.ts +11 -0
  105. package/src/help/index.ts +6 -0
  106. package/src/index.ts +163 -0
  107. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  108. package/src/keybindings/index.ts +6 -0
  109. package/src/keybindings/keybindings-command-module.ts +88 -0
  110. package/src/language/__tests__/language-command-module.test.ts +126 -0
  111. package/src/language/index.ts +6 -0
  112. package/src/language/language-command-module.ts +55 -0
  113. package/src/language/language-command.ts +52 -0
  114. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
  115. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
  116. package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
  117. package/src/mcp-activation/index.ts +12 -0
  118. package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
  119. package/src/mcp-activation/mcp-activation-command.ts +662 -0
  120. package/src/mcp-activation/mcp-model-notice.ts +99 -0
  121. package/src/mcp-activation/mcp-model-status.ts +104 -0
  122. package/src/memory/__tests__/memory-command-module.test.ts +476 -0
  123. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  124. package/src/memory/index.ts +6 -0
  125. package/src/memory/memory-command-module.ts +77 -0
  126. package/src/memory/memory-command.ts +326 -0
  127. package/src/mode/__tests__/mode-command-module.test.ts +230 -0
  128. package/src/mode/index.ts +6 -0
  129. package/src/mode/mode-command-module.ts +55 -0
  130. package/src/mode/mode-command.ts +67 -0
  131. package/src/model/__tests__/model-command-module.test.ts +80 -0
  132. package/src/model/__tests__/model-command.test.ts +265 -0
  133. package/src/model/index.ts +6 -0
  134. package/src/model/model-command-module.ts +56 -0
  135. package/src/model/model-command.ts +249 -0
  136. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  137. package/src/output-style/index.ts +6 -0
  138. package/src/output-style/output-style-command-module.ts +143 -0
  139. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  140. package/src/peers/__tests__/peers-command.test.ts +399 -0
  141. package/src/peers/index.ts +9 -0
  142. package/src/peers/peers-command-module.ts +49 -0
  143. package/src/peers/peers-command.ts +220 -0
  144. package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
  145. package/src/permissions/index.ts +6 -0
  146. package/src/permissions/permissions-command-module.ts +55 -0
  147. package/src/permissions/permissions-command.ts +89 -0
  148. package/src/plan/__tests__/plan-command.test.ts +129 -0
  149. package/src/plan/index.ts +9 -0
  150. package/src/plan/plan-command-module.ts +49 -0
  151. package/src/plan/plan-command.ts +92 -0
  152. package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
  153. package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
  154. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  155. package/src/plugin/index.ts +7 -0
  156. package/src/plugin/plugin-command-module.ts +85 -0
  157. package/src/plugin/plugin-command.ts +271 -0
  158. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  159. package/src/preset/__tests__/preset-command-module.test.ts +220 -0
  160. package/src/preset/index.ts +6 -0
  161. package/src/preset/preset-command-module.ts +80 -0
  162. package/src/preset/preset-command.ts +148 -0
  163. package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
  164. package/src/provider/__tests__/org-policy.test.ts +174 -0
  165. package/src/provider/__tests__/provider-command-module.test.ts +884 -0
  166. package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
  167. package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
  168. package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
  169. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  170. package/src/provider/__tests__/scripted-interaction.ts +40 -0
  171. package/src/provider/index.ts +32 -0
  172. package/src/provider/provider-command-execution.ts +235 -0
  173. package/src/provider/provider-command-module.ts +82 -0
  174. package/src/provider/provider-command-profile-lifecycle.ts +239 -0
  175. package/src/provider/provider-command-profile-operations.ts +218 -0
  176. package/src/provider/provider-command-profile.ts +116 -0
  177. package/src/provider/provider-command-setup.ts +79 -0
  178. package/src/provider/provider-connection-setup.ts +287 -0
  179. package/src/provider/provider-connection-validation.ts +38 -0
  180. package/src/provider/provider-onboarding.ts +73 -0
  181. package/src/provider/provider-prompt-interaction.ts +28 -0
  182. package/src/provider/provider-setup-ask.ts +67 -0
  183. package/src/provider/provider-setup-flow.ts +326 -0
  184. package/src/provider/provider-startup.ts +225 -0
  185. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  186. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  187. package/src/remote-control/index.ts +8 -0
  188. package/src/remote-control/remote-control-command-module.ts +61 -0
  189. package/src/remote-control/remote-control-command.ts +138 -0
  190. package/src/reset/__tests__/reset-command-module.test.ts +63 -0
  191. package/src/reset/index.ts +2 -0
  192. package/src/reset/reset-command-module.ts +47 -0
  193. package/src/reset/reset-command.ts +10 -0
  194. package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
  195. package/src/rewind/index.ts +2 -0
  196. package/src/rewind/rewind-command-module.ts +56 -0
  197. package/src/rewind/rewind-command.ts +279 -0
  198. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  199. package/src/sandbox/index.ts +2 -0
  200. package/src/sandbox/sandbox-command-module.ts +56 -0
  201. package/src/sandbox/sandbox-command.ts +94 -0
  202. package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
  203. package/src/schedule/__tests__/loop-command.test.ts +594 -0
  204. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  205. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  206. package/src/schedule/__tests__/schedule-command.test.ts +247 -0
  207. package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
  208. package/src/schedule/index.ts +12 -0
  209. package/src/schedule/loop-command.ts +427 -0
  210. package/src/schedule/loop-jitter.ts +41 -0
  211. package/src/schedule/schedule-command-module.ts +184 -0
  212. package/src/schedule/schedule-command.ts +161 -0
  213. package/src/schedule/schedule-spec-parser.ts +70 -0
  214. package/src/session/__tests__/model-pricing.test.ts +66 -0
  215. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  216. package/src/session/__tests__/session-command-module.test.ts +589 -0
  217. package/src/session/index.ts +17 -0
  218. package/src/session/model-pricing.ts +26 -0
  219. package/src/session/session-command-module.ts +197 -0
  220. package/src/session/session-command.ts +272 -0
  221. package/src/settings/index.ts +7 -0
  222. package/src/settings/settings-command-module.ts +47 -0
  223. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  224. package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
  225. package/src/shell/index.ts +8 -0
  226. package/src/shell/resolve-shell.ts +25 -0
  227. package/src/shell/shell-command-module.ts +53 -0
  228. package/src/shell/shell-command.ts +48 -0
  229. package/src/shell/spawn-inherited.ts +32 -0
  230. package/src/skills/__tests__/skill-read-command.test.ts +64 -0
  231. package/src/skills/__tests__/skills-command-module.test.ts +200 -0
  232. package/src/skills/index.ts +7 -0
  233. package/src/skills/skill-read-command.ts +60 -0
  234. package/src/skills/skills-command-module.ts +76 -0
  235. package/src/skills/skills-command.ts +108 -0
  236. package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
  237. package/src/statusline/index.ts +6 -0
  238. package/src/statusline/statusline-command-module.ts +55 -0
  239. package/src/statusline/statusline-command.ts +80 -0
  240. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  241. package/src/terminal-client/index.ts +5 -0
  242. package/src/terminal-client/terminal-client-commands.ts +109 -0
  243. package/src/theme/__tests__/theme-command.test.ts +204 -0
  244. package/src/theme/index.ts +12 -0
  245. package/src/theme/theme-command-module.ts +221 -0
  246. package/src/user-local/__tests__/user-local-command.test.ts +180 -0
  247. package/src/user-local/index.ts +13 -0
  248. package/src/user-local/user-local-command-constants.ts +5 -0
  249. package/src/user-local/user-local-command-module.ts +65 -0
  250. package/src/user-local/user-local-command.ts +214 -0
  251. package/src/user-local/user-local-memory-command.ts +160 -0
@@ -0,0 +1,6 @@
1
+ export {
2
+ createPresetCommandEntry,
3
+ createPresetCommandModule,
4
+ PresetCommandSource,
5
+ } from './preset-command-module.js';
6
+ export { executePresetCommand } from './preset-command.js';
@@ -0,0 +1,80 @@
1
+ import { createPresetRegistry } from '@robota-sdk/agent-preset';
2
+
3
+ import { executePresetCommand } from './preset-command.js';
4
+
5
+ import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
6
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
7
+
8
+ const PRESET_COMMAND_DESCRIPTION = 'List presets or switch the active preset';
9
+ const PRESET_ARGUMENT_HINT = 'list | <preset-id>';
10
+
11
+ /**
12
+ * Build one subcommand per registered preset id (mirrors the permission-mode subcommands).
13
+ *
14
+ * ARCH-009 LIMIT, stated rather than left implicit: this lists the BUILT-INS, because it builds a
15
+ * STATIC catalog entry at module-construction time and has no `ICommandHostContext` to reach a
16
+ * host-supplied registry. `/preset`'s own execution — listing, lookup, resolution — goes through the
17
+ * host's registry, so an embedded host with its own presets resolves and applies correctly; only the
18
+ * pre-computed subcommand hints for tab-completion omit that host's external presets.
19
+ *
20
+ * What ARCH-009 changed here is what the limit COSTS. This used to read a process-wide mutable
21
+ * registry, so the hints a host saw depended on which other host had loaded presets first. They are
22
+ * now the built-ins and nothing else — wrong the same way for everyone, and never someone else's.
23
+ *
24
+ * Closing it needs the catalog to be built per host rather than per module, which is a change to how
25
+ * command modules are constructed and not to this file. Filed separately.
26
+ */
27
+ function buildPresetSubcommands(source = 'preset'): ICommand[] {
28
+ return createPresetRegistry()
29
+ .listPresets()
30
+ .map((preset) => ({
31
+ name: preset.id,
32
+ description: preset.description,
33
+ source,
34
+ }));
35
+ }
36
+
37
+ export function createPresetCommandEntry(): ICommand {
38
+ return {
39
+ name: 'preset',
40
+ displayName: 'Agent Preset',
41
+ description: PRESET_COMMAND_DESCRIPTION,
42
+ source: 'preset',
43
+ argumentHint: PRESET_ARGUMENT_HINT,
44
+ subcommands: buildPresetSubcommands('preset'),
45
+ // User-only: a preset swaps tools, commands and permission posture; the user chooses it.
46
+ modelInvocable: false,
47
+ };
48
+ }
49
+
50
+ function createPresetSystemCommand(): ISystemCommand {
51
+ const entry = createPresetCommandEntry();
52
+ return {
53
+ name: entry.name,
54
+ displayName: entry.displayName,
55
+ description: entry.description,
56
+ requiresPermission: false,
57
+ userInvocable: true,
58
+ modelInvocable: false,
59
+ argumentHint: entry.argumentHint,
60
+ subcommands: entry.subcommands,
61
+ lifecycle: 'inline',
62
+ execute: executePresetCommand,
63
+ };
64
+ }
65
+
66
+ export class PresetCommandSource implements ICommandSource {
67
+ readonly name = 'preset';
68
+
69
+ getCommands(): ICommand[] {
70
+ return [createPresetCommandEntry()];
71
+ }
72
+ }
73
+
74
+ export function createPresetCommandModule(): ICommandModule {
75
+ return {
76
+ name: 'agent-command-preset',
77
+ commandSources: [new PresetCommandSource()],
78
+ systemCommands: [createPresetSystemCommand()],
79
+ };
80
+ }
@@ -0,0 +1,148 @@
1
+ import { selectAction } from '@robota-sdk/agent-core';
2
+ import { applyPresetToSession } from '@robota-sdk/agent-framework';
3
+ import { createPresetRegistry } from '@robota-sdk/agent-preset';
4
+
5
+ import type {
6
+ ICommandHostAdapterAccess,
7
+ ICommandHostPresetApplication,
8
+ IPresetApplicationOptions,
9
+ ICommandHostSessionAccess,
10
+ ICommandHostUserInteraction,
11
+ } from '@robota-sdk/agent-framework';
12
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
13
+ import type { IPresetSummary } from '@robota-sdk/agent-preset';
14
+
15
+ /**
16
+ * ARCH-009 — the registry `/preset` discovers through.
17
+ *
18
+ * The host supplies the instance it resolved with through the ADAPTER bag — the same path
19
+ * `/permission-mode` and `/plugin` already take to their host capabilities. A host that supplies none
20
+ * gets a fresh built-ins registry. That order is the point: while discovery read a module-global
21
+ * registry directly, two products in one process shared one mutable list, so ARCH-008's per-instance
22
+ * resolution was only half-true for an embedded host. A command runs with an `ICommandHostContext`
23
+ * and nothing else, so the context is the only path from here to the instance the shell built.
24
+ *
25
+ * The fallback is deliberate rather than transitional, and it is no longer process state: a host that
26
+ * never loads external presets has nothing to hand over, and `/preset` should still list the
27
+ * built-ins. `createPresetRegistry()` with no argument is exactly that, constructed per call.
28
+ */
29
+ /**
30
+ * What `/preset` actually needs from a registry — three members, no assertion.
31
+ *
32
+ * Narrower than `agent-preset`'s `IPresetRegistry` on purpose. Review of ARCH-009 reported the first
33
+ * version reaching that contract with three casts, one of which (`listPresets()`) widened optional
34
+ * `title`/`description` into required ones and would have printed `id — undefined: undefined` for a
35
+ * host that typechecked. The fix is not a better cast: it is asking for the shape the command uses,
36
+ * so both the built-in registry and a host adapter satisfy it STRUCTURALLY and nothing is asserted.
37
+ *
38
+ * `getPreset` returns `unknown` because presence is the whole question here.
39
+ */
40
+ interface IPresetDiscovery {
41
+ listPresets(): readonly IPresetSummary[];
42
+ getPreset(id: string): unknown;
43
+ resolvePreset(id: string, context?: unknown): IPresetApplicationOptions;
44
+ }
45
+
46
+ function presetRegistry(context: ICommandHostAdapterAccess): IPresetDiscovery {
47
+ return context.getCommandHostAdapters?.().presetRegistry ?? createPresetRegistry();
48
+ }
49
+
50
+ /** Read the active preset id from the session, defaulting when the optional seam is absent. */
51
+ function readActivePresetId(context: ICommandHostSessionAccess): string {
52
+ return context.getSession().getActivePresetId();
53
+ }
54
+
55
+ /** Build the `/preset` listing: one line per preset, marking the active one with a `*` prefix. */
56
+ function formatPresetList(active: string, registry: IPresetDiscovery): string {
57
+ const lines = registry.listPresets().map((preset) => {
58
+ const marker = preset.id === active ? '* ' : ' ';
59
+ return `${marker}${preset.id} — ${preset.title}: ${preset.description}`;
60
+ });
61
+ return ['Available presets:', ...lines].join('\n');
62
+ }
63
+
64
+ /** Build the rejection message for an unknown preset id, listing the valid ids. */
65
+ function formatUnknownPresetMessage(id: string, registry: IPresetDiscovery): string {
66
+ const ids = registry
67
+ .listPresets()
68
+ .map((preset) => preset.id)
69
+ .join(', ');
70
+ return `Unknown preset: ${id}. Available: ${ids}`;
71
+ }
72
+
73
+ /** The `/preset` (or `/preset list`) listing result. */
74
+ function presetListResult(
75
+ context: ICommandHostSessionAccess,
76
+ registry: IPresetDiscovery,
77
+ ): ICommandResult {
78
+ const active = readActivePresetId(context);
79
+ return {
80
+ message: formatPresetList(active, registry),
81
+ success: true,
82
+ data: { presets: registry.listPresets(), active },
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Ask the user to pick a preset (CMD-004 inline ask). Returns the chosen id, or `undefined` when no
88
+ * interactive renderer is attached or the user cancelled — the caller then shows the preset list.
89
+ */
90
+ async function resolvePresetViaAsk(
91
+ context: ICommandHostUserInteraction,
92
+ registry: IPresetDiscovery,
93
+ ): Promise<string | undefined> {
94
+ const ui = context.getUserInteraction();
95
+ if (!ui) return undefined;
96
+ const options = registry.listPresets().map((preset) => ({
97
+ value: preset.id,
98
+ label: preset.id,
99
+ description: preset.description,
100
+ }));
101
+ const response = await ui.ask(selectAction('preset', 'Select a preset', options));
102
+ return response.type === 'answer' ? response.values[0] : undefined;
103
+ }
104
+
105
+ export async function executePresetCommand(
106
+ context: ICommandHostPresetApplication &
107
+ ICommandHostSessionAccess &
108
+ ICommandHostUserInteraction &
109
+ ICommandHostAdapterAccess,
110
+ args: string,
111
+ ): Promise<ICommandResult> {
112
+ // ARCH-009: resolved ONCE per invocation, so listing, lookup and resolution cannot disagree — a
113
+ // host that swapped registries mid-command would otherwise report one set and apply another.
114
+ const registry = presetRegistry(context);
115
+ let id: string | undefined = args.trim().split(/\s+/)[0];
116
+
117
+ if (id === 'list') {
118
+ return presetListResult(context, registry);
119
+ }
120
+
121
+ if (id === undefined || id.length === 0) {
122
+ id = await resolvePresetViaAsk(context, registry);
123
+ if (id === undefined) {
124
+ return presetListResult(context, registry);
125
+ }
126
+ }
127
+
128
+ if (registry.getPreset(id) === undefined) {
129
+ return {
130
+ message: formatUnknownPresetMessage(id, registry),
131
+ success: false,
132
+ };
133
+ }
134
+
135
+ const resolved = registry.resolvePreset(id);
136
+ const result = await applyPresetToSession(context, id, resolved);
137
+ // INFRA-032: surface any preset command-module name that matched no live module as a non-fatal
138
+ // notice — an in-session `/preset` switch is no longer silent about a short form / typo.
139
+ const noticeLines = result.unknownCommandModules.map(
140
+ ({ name, kind }) =>
141
+ `Preset command-module "${name}" (${kind}) matched no module — expected the agent-command-* form; ignored.`,
142
+ );
143
+ return {
144
+ message: [`Switched to preset: ${id}`, ...noticeLines].join('\n'),
145
+ success: true,
146
+ data: { preset: id, unknownCommandModules: result.unknownCommandModules },
147
+ };
148
+ }
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IProviderDefinition } from '@robota-sdk/agent-core';
3
+ import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
4
+ import type { TProviderSettingsDocument } from '@robota-sdk/agent-framework';
5
+ import { createProviderCommandModule } from '../provider-command-module.js';
6
+ import { scriptedContext } from './scripted-interaction.js';
7
+
8
+ const localDefinition: IProviderDefinition = {
9
+ type: 'local',
10
+ defaults: { model: 'installed-model', baseURL: 'http://localhost:1234/v1' },
11
+ setupSteps: [
12
+ { key: 'baseURL', title: 'Server URL', defaultValue: 'http://localhost:1234/v1' },
13
+ { key: 'model', title: 'Installed model', defaultValue: 'installed-model' },
14
+ { key: 'apiKey', title: 'Server API key (optional)', masked: true, editOnly: true },
15
+ ],
16
+ requiresApiKey: false,
17
+ createProvider: () => {
18
+ throw new Error('not used');
19
+ },
20
+ };
21
+
22
+ function commands(initial: TProviderSettingsDocument = {}) {
23
+ let settings = initial;
24
+ const module = createProviderCommandModule({
25
+ providerDefinitions: [localDefinition],
26
+ env: { LOCAL_TOKEN: 'synthetic-selected-token' },
27
+ settings: {
28
+ readMergedSettings: () => settings,
29
+ readTargetSettings: () => settings,
30
+ writeTargetSettings: (next) => {
31
+ settings = next;
32
+ },
33
+ },
34
+ });
35
+ return {
36
+ executor: new SystemCommandExecutor([...(module.systemCommands ?? [])]),
37
+ read: () => settings,
38
+ module,
39
+ };
40
+ }
41
+
42
+ describe('optional provider credentials', () => {
43
+ it('adds a keyless profile after only endpoint and model answers', async () => {
44
+ const { executor, read } = commands();
45
+ const { context, requests } = scriptedContext([
46
+ { type: 'answer', values: [], text: '' },
47
+ { type: 'answer', values: [], text: '' },
48
+ ]);
49
+
50
+ const result = await executor.execute('provider', context, 'add local');
51
+
52
+ expect(result?.success).toBe(true);
53
+ expect(requests.map((request) => request.id)).toEqual([
54
+ 'provider-setup-baseURL',
55
+ 'provider-setup-model',
56
+ ]);
57
+ expect(read().providers?.local).toEqual({
58
+ type: 'local',
59
+ model: 'installed-model',
60
+ baseURL: 'http://localhost:1234/v1',
61
+ });
62
+ });
63
+
64
+ it.each([undefined, '$ENV:LOCAL_TOKEN', 'synthetic-existing-token'])(
65
+ 'allows blank optional key editing and preserves the previous credential %s',
66
+ async (apiKey) => {
67
+ const profile = {
68
+ type: 'local',
69
+ model: 'installed-model',
70
+ baseURL: 'http://localhost:1234/v1',
71
+ ...(apiKey === undefined ? {} : { apiKey }),
72
+ };
73
+ const { executor, read } = commands({ providers: { local: profile } });
74
+ const { context, requests } = scriptedContext([
75
+ { type: 'answer', values: [], text: '' },
76
+ { type: 'answer', values: [], text: 'another-installed-model' },
77
+ { type: 'answer', values: [], text: '' },
78
+ ]);
79
+
80
+ const result = await executor.execute('provider', context, 'edit local');
81
+
82
+ expect(result?.success).toBe(true);
83
+ expect(requests[2]).toMatchObject({
84
+ id: 'provider-setup-apiKey',
85
+ masked: true,
86
+ allowEmpty: true,
87
+ });
88
+ expect(JSON.stringify(requests)).not.toContain('synthetic-existing-token');
89
+ expect(read().providers?.local).toEqual({ ...profile, model: 'another-installed-model' });
90
+ },
91
+ );
92
+
93
+ it('keeps credential management user-only and advertises the edit recovery action', () => {
94
+ const { module } = commands();
95
+ const entry = module.commandSources?.[0]?.getCommands()[0];
96
+ expect(entry).toMatchObject({ modelInvocable: false });
97
+ expect(module.systemCommands?.[0]).toMatchObject({
98
+ modelInvocable: false,
99
+ userInvocable: true,
100
+ });
101
+ expect(entry?.argumentHint).toContain('edit <profile>');
102
+ expect(entry?.description).toContain('credentials');
103
+ });
104
+
105
+ it.each(['$ENV:MISSING_SERVER_TOKEN', '$ENV:'])(
106
+ 'leaves the working profile untouched when an edit supplies %s',
107
+ async (apiKey) => {
108
+ const initial = {
109
+ providers: {
110
+ local: {
111
+ type: 'local',
112
+ model: 'installed-model',
113
+ baseURL: 'http://localhost:1234/v1',
114
+ apiKey: 'synthetic-working-token',
115
+ },
116
+ },
117
+ };
118
+ const { executor, read } = commands(initial);
119
+ const { context } = scriptedContext([
120
+ { type: 'answer', values: [], text: '' },
121
+ { type: 'answer', values: [], text: '' },
122
+ { type: 'answer', values: [], text: apiKey },
123
+ ]);
124
+ await expect(executor.execute('provider', context, 'edit local')).rejects.toThrow(
125
+ 'missing apiKey',
126
+ );
127
+ expect(read()).toEqual(initial);
128
+ },
129
+ );
130
+ });
@@ -0,0 +1,174 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import type { IProviderDefinition } from '@robota-sdk/agent-core';
3
+ import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
4
+ import type {
5
+ ICommandHostContext,
6
+ IOrgPolicy,
7
+ IProviderCommandSettingsAdapter,
8
+ TProviderSettingsDocument,
9
+ } from '@robota-sdk/agent-framework';
10
+ import { createProviderCommandModule } from '../provider-command-module.js';
11
+ import { scriptedContext } from './scripted-interaction.js';
12
+ import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
13
+
14
+ const providerDefinitions: readonly IProviderDefinition[] = [
15
+ {
16
+ type: 'anthropic',
17
+ displayName: 'Anthropic',
18
+ defaults: { model: 'claude-sonnet-4-6', apiKey: '$ENV:ANTHROPIC_API_KEY' },
19
+ setupSteps: [
20
+ { key: 'apiKey', title: 'Anthropic API key', masked: true },
21
+ { key: 'model', title: 'Anthropic model', defaultValue: 'claude-sonnet-4-6' },
22
+ ],
23
+ requiresApiKey: true,
24
+ createProvider: () => {
25
+ throw new Error('not used');
26
+ },
27
+ },
28
+ {
29
+ type: 'openai',
30
+ displayName: 'OpenAI',
31
+ defaults: { model: 'gpt-4o', apiKey: '$ENV:OPENAI_API_KEY' },
32
+ setupSteps: [
33
+ { key: 'apiKey', title: 'OpenAI API key', masked: true },
34
+ { key: 'model', title: 'OpenAI model', defaultValue: 'gpt-4o' },
35
+ ],
36
+ requiresApiKey: true,
37
+ createProvider: () => {
38
+ throw new Error('not used');
39
+ },
40
+ },
41
+ ];
42
+
43
+ function createSettingsAdapter(
44
+ merged: TProviderSettingsDocument,
45
+ target: TProviderSettingsDocument = {},
46
+ ): { adapter: IProviderCommandSettingsAdapter } {
47
+ let writable = target;
48
+ return {
49
+ adapter: {
50
+ readMergedSettings: () => merged,
51
+ readTargetSettings: () => writable,
52
+ writeTargetSettings: (next) => {
53
+ writable = next;
54
+ },
55
+ },
56
+ };
57
+ }
58
+
59
+ function createExecutor(
60
+ adapter: IProviderCommandSettingsAdapter,
61
+ orgPolicy?: IOrgPolicy,
62
+ ): SystemCommandExecutor {
63
+ const module = createProviderCommandModule({ providerDefinitions, settings: adapter, orgPolicy });
64
+ return new SystemCommandExecutor([...(module.systemCommands ?? [])]);
65
+ }
66
+
67
+ /** Context with no interactive renderer attached (headless/automation). */
68
+ const headlessContext = createTestCommandHost();
69
+
70
+ describe('org policy enforcement in provider commands', () => {
71
+ afterEach(() => {
72
+ vi.unstubAllEnvs();
73
+ });
74
+ it('blocks /provider switch when target is not in allowedProviders', async () => {
75
+ const { adapter } = createSettingsAdapter({
76
+ currentProvider: 'anthropic',
77
+ providers: {
78
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
79
+ openai: { type: 'openai', model: 'gpt-4o' },
80
+ },
81
+ });
82
+ const orgPolicy: IOrgPolicy = {
83
+ allowedProviders: ['anthropic'],
84
+ adminContact: 'admin@example.com',
85
+ };
86
+
87
+ const result = await createExecutor(adapter, orgPolicy).execute(
88
+ 'provider',
89
+ headlessContext,
90
+ 'switch openai',
91
+ );
92
+
93
+ expect(result?.success).toBe(false);
94
+ expect(result?.message).toContain('"openai" is not allowed');
95
+ expect(result?.message).toContain('admin@example.com');
96
+ expect(result?.hostActions).toBeUndefined();
97
+ });
98
+
99
+ it('allows /provider switch when target is in allowedProviders', async () => {
100
+ // #3282: the switch now validates the target profile against `providerDefinitions` before
101
+ // writing anything, the same way the hot-swap it triggers would build the provider — so the
102
+ // profile needs a resolvable credential, same as a real one would.
103
+ vi.stubEnv('OPENAI_API_KEY', 'sk-test');
104
+ const { adapter } = createSettingsAdapter({
105
+ currentProvider: 'anthropic',
106
+ providers: {
107
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
108
+ openai: { type: 'openai', model: 'gpt-4o' },
109
+ },
110
+ });
111
+ const orgPolicy: IOrgPolicy = { allowedProviders: ['anthropic', 'openai'] };
112
+
113
+ const result = await createExecutor(adapter, orgPolicy).execute(
114
+ 'provider',
115
+ headlessContext,
116
+ 'switch openai',
117
+ );
118
+
119
+ expect(result?.success).toBe(true);
120
+ expect(result?.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'openai' }]);
121
+ });
122
+
123
+ it('rejects plaintext API key during edit when requireApiKeyFromEnv is true', async () => {
124
+ const { adapter } = createSettingsAdapter({
125
+ currentProvider: 'anthropic',
126
+ providers: {
127
+ anthropic: {
128
+ type: 'anthropic',
129
+ model: 'claude-sonnet-4-6',
130
+ apiKey: 'sk-existing-key',
131
+ },
132
+ },
133
+ });
134
+ const orgPolicy: IOrgPolicy = { requireApiKeyFromEnv: true, adminContact: 'sec@example.com' };
135
+
136
+ // Anthropic has 2 setup steps: apiKey then model
137
+ const { context } = scriptedContext([
138
+ { type: 'answer', values: ['anthropic'] },
139
+ { type: 'answer', values: ['edit'] },
140
+ { type: 'answer', values: [], text: 'sk-plaintext-key' },
141
+ { type: 'answer', values: [], text: 'claude-sonnet-4-6' },
142
+ ]);
143
+ const completed = await createExecutor(adapter, orgPolicy).execute('provider', context, 'list');
144
+
145
+ expect(completed?.success).toBe(false);
146
+ expect(completed?.message).toContain('environment variable references');
147
+ expect(completed?.message).toContain('sec@example.com');
148
+ });
149
+
150
+ it('allows env-ref API key during edit when requireApiKeyFromEnv is true', async () => {
151
+ vi.stubEnv('ORG_TEST_KEY', 'sk-test-resolved-value');
152
+ const { adapter } = createSettingsAdapter({
153
+ currentProvider: 'anthropic',
154
+ providers: {
155
+ anthropic: {
156
+ type: 'anthropic',
157
+ model: 'claude-sonnet-4-6',
158
+ apiKey: 'sk-existing-key',
159
+ },
160
+ },
161
+ });
162
+ const orgPolicy: IOrgPolicy = { requireApiKeyFromEnv: true };
163
+
164
+ const { context } = scriptedContext([
165
+ { type: 'answer', values: ['anthropic'] },
166
+ { type: 'answer', values: ['edit'] },
167
+ { type: 'answer', values: [], text: '$ENV:ORG_TEST_KEY' },
168
+ { type: 'answer', values: [], text: 'claude-opus-4-5' },
169
+ ]);
170
+ const completed = await createExecutor(adapter, orgPolicy).execute('provider', context, 'list');
171
+
172
+ expect(completed?.success).toBe(true);
173
+ });
174
+ });