@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,884 @@
1
+ import { 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
+ IProviderCommandSettingsAdapter,
7
+ TProviderSettingsDocument,
8
+ } from '@robota-sdk/agent-framework';
9
+ import { createProviderCommandModule } from '../provider-command-module.js';
10
+ import { scriptedContext } from './scripted-interaction.js';
11
+ import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
12
+
13
+ const providerDefinitions: readonly IProviderDefinition[] = [
14
+ {
15
+ type: 'openai',
16
+ displayName: 'OpenAI Compatible',
17
+ description: 'Use OpenAI or an OpenAI-compatible endpoint',
18
+ defaults: {
19
+ model: 'supergemma4-26b-uncensored-v2',
20
+ apiKey: 'lm-studio',
21
+ baseURL: 'http://localhost:1234/v1',
22
+ },
23
+ setupHelpLinks: [
24
+ {
25
+ kind: 'official',
26
+ label: 'OpenAI-compatible local server docs',
27
+ url: 'https://lmstudio.ai/docs/developer',
28
+ },
29
+ ],
30
+ setupSteps: [
31
+ {
32
+ key: 'baseURL',
33
+ title: 'OpenAI-compatible base URL',
34
+ defaultValue: 'http://localhost:1234/v1',
35
+ },
36
+ {
37
+ key: 'model',
38
+ title: 'OpenAI-compatible model',
39
+ defaultValue: 'supergemma4-26b-uncensored-v2',
40
+ },
41
+ {
42
+ key: 'apiKey',
43
+ title: 'OpenAI-compatible API key',
44
+ defaultValue: 'lm-studio',
45
+ masked: true,
46
+ },
47
+ ],
48
+ requiresApiKey: true,
49
+ createProvider: () => {
50
+ throw new Error('not used');
51
+ },
52
+ },
53
+ {
54
+ type: 'anthropic',
55
+ defaults: {
56
+ model: 'claude-sonnet-4-6',
57
+ apiKey: '$ENV:ANTHROPIC_API_KEY',
58
+ },
59
+ setupSteps: [
60
+ { key: 'apiKey', title: 'anthropic API key', masked: true },
61
+ { key: 'model', title: 'anthropic model', defaultValue: 'claude-sonnet-4-6' },
62
+ ],
63
+ requiresApiKey: true,
64
+ createProvider: () => {
65
+ throw new Error('not used');
66
+ },
67
+ },
68
+ ];
69
+
70
+ function createSettingsAdapter(
71
+ merged: TProviderSettingsDocument,
72
+ target: TProviderSettingsDocument = {},
73
+ ): {
74
+ adapter: IProviderCommandSettingsAdapter;
75
+ readTarget: () => TProviderSettingsDocument;
76
+ } {
77
+ let writable = target;
78
+ return {
79
+ adapter: {
80
+ readMergedSettings: () => merged,
81
+ readTargetSettings: () => writable,
82
+ writeTargetSettings: (next) => {
83
+ writable = next;
84
+ },
85
+ },
86
+ readTarget: () => writable,
87
+ };
88
+ }
89
+
90
+ function createExecutor(adapter: IProviderCommandSettingsAdapter): SystemCommandExecutor {
91
+ const module = createProviderCommandModule({
92
+ providerDefinitions,
93
+ settings: adapter,
94
+ });
95
+ return new SystemCommandExecutor([...(module.systemCommands ?? [])]);
96
+ }
97
+
98
+ /** Context with no interactive renderer attached (headless/automation). */
99
+ const headlessContext = createTestCommandHost();
100
+
101
+ describe('createProviderCommandModule', () => {
102
+ it('contributes /provider metadata and executable command from the provider package', () => {
103
+ const { adapter } = createSettingsAdapter({});
104
+ const module = createProviderCommandModule({ providerDefinitions, settings: adapter });
105
+ const commands = module.commandSources?.flatMap((source) => source.getCommands()) ?? [];
106
+
107
+ expect(module.name).toBe('agent-command-provider');
108
+ expect(commands.map((command) => command.name)).toEqual(['provider']);
109
+ expect(commands[0]?.subcommands?.map((command) => command.name)).toContain('switch');
110
+ expect(module.systemCommands?.map((command) => command.name)).toEqual(['provider']);
111
+ });
112
+
113
+ it('asks the user to pick a provider profile from injected merged settings', async () => {
114
+ const { adapter } = createSettingsAdapter({
115
+ currentProvider: 'openai',
116
+ providers: {
117
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
118
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
119
+ },
120
+ });
121
+
122
+ const { context, requests } = scriptedContext([{ type: 'cancelled' }]);
123
+ const result = await createExecutor(adapter).execute('provider', context, 'list');
124
+
125
+ expect(result?.success).toBe(true);
126
+ const picker = requests[0];
127
+ expect(picker?.title).toBe('Select provider profile');
128
+ expect(picker?.options?.map((option) => option.label)).toEqual(
129
+ expect.arrayContaining([
130
+ expect.stringContaining('* openai'),
131
+ expect.stringContaining('anthropic'),
132
+ ]),
133
+ );
134
+ });
135
+
136
+ it('returns a plain text list when no interactive renderer is attached', async () => {
137
+ const { adapter } = createSettingsAdapter({
138
+ currentProvider: 'openai',
139
+ providers: {
140
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
141
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
142
+ },
143
+ });
144
+
145
+ const result = await createExecutor(adapter).execute('provider', headlessContext, 'list');
146
+
147
+ expect(result?.success).toBe(true);
148
+ expect(result?.message).toContain('* openai');
149
+ expect(result?.message).toContain('anthropic');
150
+ });
151
+
152
+ it('opens a provider profile action menu from /provider after picking a profile', async () => {
153
+ const { adapter } = createSettingsAdapter({
154
+ currentProvider: 'openai',
155
+ providers: {
156
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
157
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
158
+ },
159
+ });
160
+
161
+ const { context, requests } = scriptedContext([
162
+ { type: 'answer', values: ['anthropic'] },
163
+ { type: 'cancelled' },
164
+ ]);
165
+ const result = await createExecutor(adapter).execute('provider', context, '');
166
+
167
+ expect(result?.success).toBe(true);
168
+ expect(requests[0]?.title).toBe('Select provider profile');
169
+ expect(requests[1]?.title).toBe('Provider profile: anthropic');
170
+ });
171
+
172
+ it('#3282 §2: the profile action menu offers Switch, Edit, Test, Duplicate and Delete — no separate Cancel option (the picker\'s own Cancel affordance is the only one)', async () => {
173
+ const { adapter } = createSettingsAdapter({
174
+ currentProvider: 'openai',
175
+ providers: {
176
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
177
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
178
+ },
179
+ });
180
+
181
+ const { context, requests } = scriptedContext([
182
+ { type: 'answer', values: ['anthropic'] },
183
+ { type: 'cancelled' },
184
+ ]);
185
+ await createExecutor(adapter).execute('provider', context, '');
186
+
187
+ const actionMenu = requests[1];
188
+ expect(actionMenu?.options?.map((option) => option.value)).toEqual([
189
+ 'switch',
190
+ 'edit',
191
+ 'test',
192
+ 'duplicate',
193
+ 'delete',
194
+ ]);
195
+ });
196
+
197
+ it('switches provider immediately via /provider switch without confirmation dialog', async () => {
198
+ const { adapter, readTarget } = createSettingsAdapter(
199
+ {
200
+ currentProvider: 'anthropic',
201
+ providers: {
202
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
203
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
204
+ },
205
+ },
206
+ {},
207
+ );
208
+
209
+ const result = await createExecutor(adapter).execute(
210
+ 'provider',
211
+ headlessContext,
212
+ 'switch openai',
213
+ );
214
+
215
+ expect(result?.message).toBe(
216
+ 'Switched to openai (supergemma4-26b-uncensored-v2). History preserved.',
217
+ );
218
+ expect(readTarget().currentProvider).toBe('openai');
219
+ expect(result?.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'openai' }]);
220
+ });
221
+
222
+ it('switches from the provider profile action menu immediately without confirmation', async () => {
223
+ const { adapter, readTarget } = createSettingsAdapter(
224
+ {
225
+ currentProvider: 'anthropic',
226
+ providers: {
227
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
228
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
229
+ },
230
+ },
231
+ {},
232
+ );
233
+
234
+ const { context } = scriptedContext([
235
+ { type: 'answer', values: ['openai'] },
236
+ { type: 'answer', values: ['switch'] },
237
+ ]);
238
+ const result = await createExecutor(adapter).execute('provider', context, 'list');
239
+
240
+ expect(result?.message).toBe(
241
+ 'Switched to openai (supergemma4-26b-uncensored-v2). History preserved.',
242
+ );
243
+ expect(readTarget().currentProvider).toBe('openai');
244
+ expect(result?.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'openai' }]);
245
+ });
246
+
247
+ it('edits a provider profile through provider-owned setup metadata without exposing secrets', async () => {
248
+ const { adapter, readTarget } = createSettingsAdapter(
249
+ {
250
+ currentProvider: 'anthropic',
251
+ providers: {
252
+ anthropic: {
253
+ type: 'anthropic',
254
+ model: 'claude-sonnet-4-6',
255
+ apiKey: 'sk-ant-secret',
256
+ },
257
+ },
258
+ },
259
+ {},
260
+ );
261
+
262
+ const { context, requests } = scriptedContext([
263
+ { type: 'answer', values: ['anthropic'] },
264
+ { type: 'answer', values: ['edit'] },
265
+ { type: 'answer', values: [], text: '' },
266
+ { type: 'answer', values: [], text: 'claude-opus-4-5' },
267
+ ]);
268
+ const completed = await createExecutor(adapter).execute('provider', context, 'list');
269
+
270
+ const apiKeyRequest = requests[2];
271
+ expect(apiKeyRequest?.title).toBe('anthropic API key');
272
+ expect(apiKeyRequest?.allowFreeText).toBe(true);
273
+ expect(apiKeyRequest?.placeholder).toBe('(unchanged)');
274
+ expect(apiKeyRequest?.masked).toBe(true);
275
+
276
+ expect(readTarget()).toMatchObject({
277
+ providers: {
278
+ anthropic: {
279
+ type: 'anthropic',
280
+ model: 'claude-opus-4-5',
281
+ apiKey: 'sk-ant-secret',
282
+ },
283
+ },
284
+ });
285
+ expect(completed?.message).toBe('Provider anthropic updated. Switching...');
286
+ expect(completed?.hostActions).toEqual([
287
+ { type: 'provider-hot-swap', profileName: 'anthropic' },
288
+ ]);
289
+ });
290
+
291
+ it('duplicates a provider profile from the action menu without switching sessions', async () => {
292
+ const { adapter, readTarget } = createSettingsAdapter({
293
+ currentProvider: 'openai',
294
+ providers: {
295
+ openai: {
296
+ type: 'openai',
297
+ model: 'supergemma4-26b-uncensored-v2',
298
+ apiKey: 'lm-studio',
299
+ },
300
+ },
301
+ });
302
+
303
+ const { context, requests } = scriptedContext([
304
+ { type: 'answer', values: ['openai'] },
305
+ { type: 'answer', values: ['duplicate'] },
306
+ { type: 'answer', values: [], text: '' },
307
+ ]);
308
+ const completed = await createExecutor(adapter).execute('provider', context, 'list');
309
+
310
+ expect(requests[2]?.title).toBe('Duplicate openai as');
311
+ expect(requests[2]?.placeholder).toBe('openai-copy');
312
+ expect(readTarget()).toMatchObject({
313
+ providers: {
314
+ 'openai-copy': {
315
+ type: 'openai',
316
+ model: 'supergemma4-26b-uncensored-v2',
317
+ apiKey: 'lm-studio',
318
+ },
319
+ },
320
+ });
321
+ expect(completed?.hostActions).toBeUndefined();
322
+ });
323
+
324
+ it('deletes inactive provider profiles after confirmation', async () => {
325
+ const { adapter, readTarget } = createSettingsAdapter(
326
+ {
327
+ currentProvider: 'openai',
328
+ providers: {
329
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
330
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
331
+ },
332
+ },
333
+ {
334
+ currentProvider: 'openai',
335
+ providers: {
336
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
337
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
338
+ },
339
+ },
340
+ );
341
+
342
+ const { context, requests } = scriptedContext([
343
+ { type: 'answer', values: ['anthropic'] },
344
+ { type: 'answer', values: ['delete'] },
345
+ { type: 'answer', values: ['yes'] },
346
+ ]);
347
+ const completed = await createExecutor(adapter).execute('provider', context, 'list');
348
+
349
+ // The confirm prompt must actually be issued — guards against a silent drop of the
350
+ // confirmation step that would still "delete and pass".
351
+ expect(requests[2]?.title).toBe('Delete profile "anthropic"?');
352
+ expect(completed?.message).toBe('Provider profile deleted: anthropic.');
353
+ expect(readTarget()).toEqual({
354
+ currentProvider: 'openai',
355
+ providers: {
356
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
357
+ },
358
+ });
359
+ });
360
+
361
+ it('requires a replacement before deleting the active provider profile', async () => {
362
+ const { adapter, readTarget } = createSettingsAdapter(
363
+ {
364
+ currentProvider: 'anthropic',
365
+ providers: {
366
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
367
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
368
+ },
369
+ },
370
+ {
371
+ currentProvider: 'anthropic',
372
+ providers: {
373
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
374
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
375
+ },
376
+ },
377
+ );
378
+
379
+ const { context, requests } = scriptedContext([
380
+ { type: 'answer', values: ['anthropic'] },
381
+ { type: 'answer', values: ['delete'] },
382
+ { type: 'answer', values: ['yes'] },
383
+ { type: 'answer', values: ['openai'] },
384
+ ]);
385
+ const completed = await createExecutor(adapter).execute('provider', context, 'list');
386
+
387
+ // The confirm precedes the replacement picker — assert both so neither step can be dropped silently.
388
+ expect(requests[2]?.title).toBe('Delete profile "anthropic"?');
389
+ expect(requests[3]?.title).toBe('Replacement provider for anthropic');
390
+ expect(readTarget()).toEqual({
391
+ currentProvider: 'openai',
392
+ providers: {
393
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
394
+ },
395
+ });
396
+ expect(completed?.hostActions).toEqual([
397
+ {
398
+ type: 'session-restart',
399
+ reason: 'other',
400
+ message: 'Provider delete restart',
401
+ },
402
+ ]);
403
+ });
404
+
405
+ it('blocks deletion of inherited provider profiles instead of pretending to remove them', async () => {
406
+ const { adapter } = createSettingsAdapter(
407
+ {
408
+ currentProvider: 'openai',
409
+ providers: {
410
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
411
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
412
+ },
413
+ },
414
+ {},
415
+ );
416
+
417
+ const { context } = scriptedContext([
418
+ { type: 'answer', values: ['anthropic'] },
419
+ { type: 'answer', values: ['delete'] },
420
+ ]);
421
+ const result = await createExecutor(adapter).execute('provider', context, 'list');
422
+
423
+ expect(result?.success).toBe(false);
424
+ expect(result?.message).toContain('not stored in the active write target');
425
+ });
426
+
427
+ it('owns provider setup flow, writes settings, and hot-swaps a session actually in setup mode (#3282 §3)', async () => {
428
+ const { adapter, readTarget } = createSettingsAdapter({}, {});
429
+
430
+ // #3282 §3: the session's own live `isSetupRequired()` says so — a served runtime's setup mode has
431
+ // no session to restart into, so this hot-swaps a running placeholder instead. Restarting a live
432
+ // session that already has a provider (adding a second profile, below) is unaffected.
433
+ const { context, requests } = scriptedContext(
434
+ [
435
+ { type: 'answer', values: [], text: '' },
436
+ { type: 'answer', values: [], text: '' },
437
+ { type: 'answer', values: [], text: '' },
438
+ ],
439
+ true,
440
+ );
441
+ const completed = await createExecutor(adapter).execute('provider', context, 'add openai');
442
+
443
+ expect(requests[0]?.title).toBe('OpenAI-compatible base URL');
444
+ expect(requests[0]?.description).toBe(
445
+ ' Setup help: Official: OpenAI-compatible local server docs - https://lmstudio.ai/docs/developer',
446
+ );
447
+ expect(requests[0]?.placeholder).toBe('http://localhost:1234/v1');
448
+ expect(requests[0]?.allowEmpty).toBe(true);
449
+ // All three setup steps must be asked in order — a dropped step would otherwise be hidden by the
450
+ // scripted double returning the surplus answers to whatever remains.
451
+ expect(requests.map((request) => request.title)).toEqual([
452
+ 'OpenAI-compatible base URL',
453
+ 'OpenAI-compatible model',
454
+ 'OpenAI-compatible API key',
455
+ ]);
456
+
457
+ expect(readTarget()).toMatchObject({
458
+ currentProvider: 'openai',
459
+ providers: {
460
+ openai: {
461
+ type: 'openai',
462
+ baseURL: 'http://localhost:1234/v1',
463
+ model: 'supergemma4-26b-uncensored-v2',
464
+ apiKey: 'lm-studio',
465
+ },
466
+ },
467
+ });
468
+ expect(completed?.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'openai' }]);
469
+ });
470
+
471
+ it('restarts (never hot-swaps) an env-default session with no persisted provider either (#3282 §3)', async () => {
472
+ // A session that started from a recognized provider env key (zero-config) has no `currentProvider`
473
+ // persisted, exactly like a served runtime's setup-mode session — but it is NOT in setup mode.
474
+ // Inferring "first ever" from settings alone would hot-swap it silently; only the session's own
475
+ // `isSetupRequired()` (false here, the default) tells the two apart.
476
+ const { adapter, readTarget } = createSettingsAdapter({}, {});
477
+
478
+ const { context } = scriptedContext([
479
+ { type: 'answer', values: [], text: '' },
480
+ { type: 'answer', values: [], text: '' },
481
+ { type: 'answer', values: [], text: '' },
482
+ ]);
483
+ const completed = await createExecutor(adapter).execute('provider', context, 'add openai');
484
+
485
+ expect(readTarget()).toMatchObject({ currentProvider: 'openai' });
486
+ expect(completed?.hostActions).toEqual([
487
+ { type: 'session-restart', reason: 'other', message: 'Provider setup restart' },
488
+ ]);
489
+ });
490
+
491
+ it('asks the user to pick a provider type when /provider add is called without one', async () => {
492
+ const { adapter, readTarget } = createSettingsAdapter({}, {});
493
+
494
+ const { context, requests } = scriptedContext([
495
+ { type: 'answer', values: ['openai'] },
496
+ { type: 'answer', values: [], text: '' },
497
+ { type: 'answer', values: [], text: '' },
498
+ { type: 'answer', values: [], text: '' },
499
+ ]);
500
+ const completed = await createExecutor(adapter).execute('provider', context, 'add');
501
+
502
+ expect(requests[0]?.title).toBe('Select provider');
503
+ expect(requests[0]?.options?.map((option) => option.value)).toEqual(['openai', 'anthropic']);
504
+ expect(readTarget()).toMatchObject({
505
+ currentProvider: 'openai',
506
+ providers: { openai: { type: 'openai' } },
507
+ });
508
+ expect(completed?.success).toBe(true);
509
+ });
510
+
511
+ it('reports usage for /provider add without a type when no renderer is attached', async () => {
512
+ const { adapter } = createSettingsAdapter({}, {});
513
+
514
+ const result = await createExecutor(adapter).execute('provider', headlessContext, 'add');
515
+
516
+ expect(result?.success).toBe(false);
517
+ expect(result?.message).toContain('Usage: provider add <type>');
518
+ });
519
+
520
+ it('creates another profile when provider type already exists', async () => {
521
+ const { adapter, readTarget } = createSettingsAdapter(
522
+ {
523
+ currentProvider: 'openai',
524
+ providers: {
525
+ openai: {
526
+ type: 'openai',
527
+ baseURL: 'http://localhost:1234/v1',
528
+ model: 'supergemma4-26b-uncensored-v2',
529
+ apiKey: 'lm-studio',
530
+ },
531
+ },
532
+ },
533
+ {},
534
+ );
535
+
536
+ const { context } = scriptedContext([
537
+ { type: 'answer', values: [], text: '' },
538
+ { type: 'answer', values: [], text: '' },
539
+ { type: 'answer', values: [], text: '' },
540
+ ]);
541
+ const completed = await createExecutor(adapter).execute('provider', context, 'add openai');
542
+
543
+ // #3282 §3: this session is not in setup mode (scriptedContext's isSetupRequired default, false) —
544
+ // it restarts to pick up the new profile, exactly as an ordinary session with one already active.
545
+ expect(completed?.hostActions).toEqual([
546
+ { type: 'session-restart', reason: 'other', message: 'Provider setup restart' },
547
+ ]);
548
+ expect(readTarget()).toMatchObject({
549
+ currentProvider: 'openai-2',
550
+ providers: {
551
+ 'openai-2': {
552
+ type: 'openai',
553
+ baseURL: 'http://localhost:1234/v1',
554
+ model: 'supergemma4-26b-uncensored-v2',
555
+ apiKey: 'lm-studio',
556
+ },
557
+ },
558
+ });
559
+ });
560
+
561
+ it('validates provider profile before probe without blocking manual configuration', async () => {
562
+ const probe = vi.fn().mockResolvedValue({ ok: false, message: 'Connection failed' });
563
+ const { adapter } = createSettingsAdapter({
564
+ currentProvider: 'openai',
565
+ providers: {
566
+ openai: {
567
+ type: 'openai',
568
+ model: 'supergemma4-26b-uncensored-v2',
569
+ baseURL: 'http://localhost:1234/v1',
570
+ },
571
+ },
572
+ });
573
+
574
+ const module = createProviderCommandModule({
575
+ providerDefinitions: [
576
+ {
577
+ ...providerDefinitions[0]!,
578
+ requiresApiKey: false,
579
+ probeProfile: probe,
580
+ },
581
+ ],
582
+ settings: adapter,
583
+ });
584
+ const result = await new SystemCommandExecutor([...(module.systemCommands ?? [])]).execute(
585
+ 'provider',
586
+ headlessContext,
587
+ 'test openai',
588
+ );
589
+
590
+ expect(result?.success).toBe(true);
591
+ expect(result?.message).toContain('Connection failed');
592
+ expect(result?.message).toContain('manual configuration can continue');
593
+ expect(probe).toHaveBeenCalled();
594
+ });
595
+
596
+ // #3282 §4b: direct, non-interactive forms of Edit/Duplicate/Delete — the Settings screen already
597
+ // knows which profile a button belongs to, so it names it directly instead of repeating the
598
+ // profile-action menu's own "which profile" ask.
599
+ describe('direct edit/duplicate/delete subcommands (#3282 §4b)', () => {
600
+ it('edits a named profile directly, skipping the "which profile" ask', async () => {
601
+ const { adapter, readTarget } = createSettingsAdapter(
602
+ {
603
+ currentProvider: 'anthropic',
604
+ providers: {
605
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6', apiKey: 'sk-ant-secret' },
606
+ },
607
+ },
608
+ {},
609
+ );
610
+
611
+ const { context, requests } = scriptedContext([
612
+ { type: 'answer', values: [], text: '' },
613
+ { type: 'answer', values: [], text: 'claude-opus-4-5' },
614
+ ]);
615
+ const completed = await createExecutor(adapter).execute('provider', context, 'edit anthropic');
616
+
617
+ expect(requests[0]?.title).toBe('anthropic API key');
618
+ expect(readTarget()).toMatchObject({
619
+ providers: { anthropic: { model: 'claude-opus-4-5', apiKey: 'sk-ant-secret' } },
620
+ });
621
+ expect(completed?.message).toBe('Provider anthropic updated. Switching...');
622
+ });
623
+
624
+ it('requires an interactive renderer to edit', async () => {
625
+ const { adapter } = createSettingsAdapter({
626
+ currentProvider: 'anthropic',
627
+ providers: { anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' } },
628
+ });
629
+
630
+ const result = await createExecutor(adapter).execute('provider', headlessContext, 'edit anthropic');
631
+
632
+ expect(result?.success).toBe(false);
633
+ expect(result?.message).toContain('interactive session');
634
+ });
635
+
636
+ it('duplicates a named profile directly, skipping the "which profile" ask', async () => {
637
+ const { adapter, readTarget } = createSettingsAdapter({
638
+ currentProvider: 'openai',
639
+ providers: {
640
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2', apiKey: 'lm-studio' },
641
+ },
642
+ });
643
+
644
+ const { context, requests } = scriptedContext([{ type: 'answer', values: [], text: '' }]);
645
+ const completed = await createExecutor(adapter).execute('provider', context, 'duplicate openai');
646
+
647
+ expect(requests[0]?.title).toBe('Duplicate openai as');
648
+ expect(readTarget()).toMatchObject({
649
+ providers: { 'openai-copy': { type: 'openai', model: 'supergemma4-26b-uncensored-v2' } },
650
+ });
651
+ expect(completed?.hostActions).toBeUndefined();
652
+ });
653
+
654
+ it('the Settings screen\'s own write (--confirmed) deletes with no confirm ask and no replacement ask', async () => {
655
+ const { adapter, readTarget } = createSettingsAdapter(
656
+ {
657
+ currentProvider: 'openai',
658
+ providers: {
659
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
660
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
661
+ },
662
+ },
663
+ {
664
+ currentProvider: 'openai',
665
+ providers: {
666
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
667
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
668
+ },
669
+ },
670
+ );
671
+
672
+ const { context, requests } = scriptedContext([]);
673
+ const completed = await createExecutor(adapter).execute(
674
+ 'provider',
675
+ context,
676
+ 'delete anthropic --confirmed',
677
+ );
678
+
679
+ expect(requests).toEqual([]);
680
+ expect(completed?.message).toBe('Provider profile deleted: anthropic.');
681
+ expect(readTarget()).toEqual({
682
+ currentProvider: 'openai',
683
+ providers: { openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' } },
684
+ });
685
+ });
686
+
687
+ it('a plainly-typed delete confirms first, interactively, before deleting', async () => {
688
+ const { adapter, readTarget } = createSettingsAdapter(
689
+ {
690
+ currentProvider: 'openai',
691
+ providers: {
692
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
693
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
694
+ },
695
+ },
696
+ {
697
+ currentProvider: 'openai',
698
+ providers: {
699
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
700
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
701
+ },
702
+ },
703
+ );
704
+
705
+ const { context, requests } = scriptedContext([{ type: 'answer', values: ['yes'] }]);
706
+ const completed = await createExecutor(adapter).execute('provider', context, 'delete anthropic');
707
+
708
+ expect(requests[0]?.title).toBe('Delete profile "anthropic"?');
709
+ expect(completed?.message).toBe('Provider profile deleted: anthropic.');
710
+ expect(readTarget()).toEqual({
711
+ currentProvider: 'openai',
712
+ providers: { openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' } },
713
+ });
714
+ });
715
+
716
+ it('a declined confirm cancels a plainly-typed delete, deleting nothing', async () => {
717
+ const settings = {
718
+ currentProvider: 'openai',
719
+ providers: {
720
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
721
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
722
+ },
723
+ };
724
+ const { adapter, readTarget } = createSettingsAdapter(settings, settings);
725
+
726
+ const { context } = scriptedContext([{ type: 'answer', values: ['no'] }]);
727
+ const result = await createExecutor(adapter).execute('provider', context, 'delete anthropic');
728
+
729
+ expect(result).toEqual({ message: 'Provider delete cancelled.', success: true });
730
+ expect(readTarget().providers?.['anthropic']).toBeDefined();
731
+ });
732
+
733
+ it('a headless (non-interactive) delete proceeds without asking, matching /clear\'s convention', async () => {
734
+ const settings = {
735
+ currentProvider: 'openai',
736
+ providers: {
737
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
738
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
739
+ },
740
+ };
741
+ const { adapter, readTarget } = createSettingsAdapter(settings, settings);
742
+
743
+ const result = await createExecutor(adapter).execute('provider', headlessContext, 'delete anthropic');
744
+
745
+ expect(result?.message).toBe('Provider profile deleted: anthropic.');
746
+ expect(readTarget().providers?.['anthropic']).toBeUndefined();
747
+ });
748
+
749
+ it('refuses to delete the profile in use, unlike the menu\'s Delete which asks for a replacement', async () => {
750
+ const { adapter, readTarget } = createSettingsAdapter(
751
+ {
752
+ currentProvider: 'anthropic',
753
+ providers: {
754
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
755
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
756
+ },
757
+ },
758
+ {
759
+ currentProvider: 'anthropic',
760
+ providers: {
761
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
762
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
763
+ },
764
+ },
765
+ );
766
+
767
+ const { context, requests } = scriptedContext([]);
768
+ const result = await createExecutor(adapter).execute(
769
+ 'provider',
770
+ context,
771
+ 'delete anthropic --confirmed',
772
+ );
773
+
774
+ expect(result?.success).toBe(false);
775
+ expect(result?.message).toBe('Cannot delete the profile in use. Switch to another profile first.');
776
+ expect(requests).toEqual([]); // never asks — a modal write has nowhere to ask a follow-up
777
+ expect(readTarget().providers?.['anthropic']).toBeDefined(); // nothing was deleted
778
+ });
779
+
780
+ it('refuses to delete the only provider profile', async () => {
781
+ const { adapter } = createSettingsAdapter(
782
+ { currentProvider: 'openai', providers: { openai: { type: 'openai', model: 'x' } } },
783
+ { currentProvider: 'openai', providers: { openai: { type: 'openai', model: 'x' } } },
784
+ );
785
+
786
+ const result = await createExecutor(adapter).execute('provider', headlessContext, 'delete openai');
787
+
788
+ expect(result?.success).toBe(false);
789
+ expect(result?.message).toBe('Cannot delete the only provider profile.');
790
+ });
791
+
792
+ it('reports an unknown profile for a direct delete', async () => {
793
+ const { adapter } = createSettingsAdapter({
794
+ currentProvider: 'openai',
795
+ providers: {
796
+ openai: { type: 'openai', model: 'x' },
797
+ anthropic: { type: 'anthropic', model: 'y' },
798
+ },
799
+ });
800
+
801
+ const result = await createExecutor(adapter).execute('provider', headlessContext, 'delete ghost');
802
+
803
+ expect(result?.success).toBe(false);
804
+ expect(result?.message).toBe('Provider profile "ghost" was not found.');
805
+ });
806
+ });
807
+
808
+ // #3459: a host that withholds its own credentials from `process.env` checks `$ENV:` credentials
809
+ // against the snapshot it took first, which it hands the command as `env`.
810
+ describe('credentials the host withheld from process.env', () => {
811
+ const settings: TProviderSettingsDocument = {
812
+ currentProvider: 'openai',
813
+ providers: {
814
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
815
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
816
+ },
817
+ };
818
+ const snapshot = { ANTHROPIC_API_KEY: 'snapshot-key' };
819
+
820
+ function withoutLiveKey<T>(run: () => Promise<T>): Promise<T> {
821
+ const live = process.env['ANTHROPIC_API_KEY'];
822
+ delete process.env['ANTHROPIC_API_KEY'];
823
+ return run().finally(() => {
824
+ if (live !== undefined) process.env['ANTHROPIC_API_KEY'] = live;
825
+ });
826
+ }
827
+
828
+ function executorWithSnapshot(adapter: IProviderCommandSettingsAdapter): SystemCommandExecutor {
829
+ const module = createProviderCommandModule({ providerDefinitions, settings: adapter, env: snapshot });
830
+ return new SystemCommandExecutor([...(module.systemCommands ?? [])]);
831
+ }
832
+
833
+ it('switches to a profile whose key is only in the snapshot', async () => {
834
+ const { adapter, readTarget } = createSettingsAdapter(settings);
835
+ const result = await withoutLiveKey(() =>
836
+ executorWithSnapshot(adapter).execute('provider', headlessContext, 'switch anthropic'),
837
+ );
838
+
839
+ expect(result?.success).toBe(true);
840
+ expect(readTarget().currentProvider).toBe('anthropic');
841
+ });
842
+
843
+ it('adds a profile whose default key is only in the snapshot', async () => {
844
+ const { adapter, readTarget } = createSettingsAdapter(settings);
845
+ const { context } = scriptedContext([
846
+ { type: 'answer', values: [], text: '' },
847
+ { type: 'answer', values: [], text: '' },
848
+ ]);
849
+ const module = createProviderCommandModule({ providerDefinitions, settings: adapter, env: snapshot });
850
+ const result = await withoutLiveKey(() =>
851
+ new SystemCommandExecutor([...(module.systemCommands ?? [])]).execute('provider', context, 'add anthropic'),
852
+ );
853
+
854
+ expect(result?.success).not.toBe(false);
855
+ expect(readTarget().providers?.['anthropic-2']).toMatchObject({ type: 'anthropic' });
856
+ });
857
+
858
+ it('edits a profile whose default key is only in the snapshot', async () => {
859
+ const { adapter, readTarget } = createSettingsAdapter(settings);
860
+ const { context } = scriptedContext([
861
+ { type: 'answer', values: [], text: '' },
862
+ { type: 'answer', values: [], text: 'claude-opus-4-5' },
863
+ ]);
864
+ const module = createProviderCommandModule({ providerDefinitions, settings: adapter, env: snapshot });
865
+ const result = await withoutLiveKey(() =>
866
+ new SystemCommandExecutor([...(module.systemCommands ?? [])]).execute('provider', context, 'edit anthropic'),
867
+ );
868
+
869
+ expect(result?.message).toBe('Provider anthropic updated.');
870
+ expect(readTarget().providers?.['anthropic']).toMatchObject({ model: 'claude-opus-4-5' });
871
+ });
872
+
873
+ it('tests a profile whose key is only in the snapshot', async () => {
874
+ const { adapter } = createSettingsAdapter(settings);
875
+ const result = await withoutLiveKey(() =>
876
+ executorWithSnapshot(adapter).execute('provider', headlessContext, 'test anthropic'),
877
+ );
878
+
879
+ expect(result?.message).toBe(
880
+ 'Provider "anthropic" test passed: Profile fields are valid; no endpoint probe configured.',
881
+ );
882
+ });
883
+ });
884
+ });