@robota-sdk/agent-command 3.0.0-beta.90 → 3.0.0-beta.93

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.
@@ -11,7 +11,11 @@ import {
11
11
  buildProviderDuplicate,
12
12
  buildProviderProfileDelete,
13
13
  } from './provider-command-profile-lifecycle.js';
14
- import { buildProviderEdit, buildProviderSwitch } from './provider-command-profile-operations.js';
14
+ import {
15
+ buildProviderEdit,
16
+ buildProviderReconnect,
17
+ buildProviderSwitch,
18
+ } from './provider-command-profile-operations.js';
15
19
  import { askProviderProfileSelection } from './provider-command-profile.js';
16
20
  import { createSetupFlow, runProviderAddSetup } from './provider-command-setup.js';
17
21
  import { formatProviderSetupChoiceLabel } from './provider-setup-flow.js';
@@ -72,8 +76,15 @@ export async function executeProviderCommand(
72
76
  if (!profileArg) return { message: 'Usage: provider edit <profile>', success: false };
73
77
  return buildProviderEdit(ui, profileArg, options);
74
78
  }
79
+ if (subcommand === 'reconnect') {
80
+ if (!ui)
81
+ return { message: 'Provider reconnect requires an interactive session.', success: false };
82
+ if (!profileArg) return { message: 'Usage: /provider reconnect <profile>', success: false };
83
+ return buildProviderReconnect(ui, profileArg, options);
84
+ }
75
85
  if (subcommand === 'duplicate') {
76
- if (!ui) return { message: 'Provider duplicate requires an interactive session.', success: false };
86
+ if (!ui)
87
+ return { message: 'Provider duplicate requires an interactive session.', success: false };
77
88
  if (!profileArg) return { message: 'Usage: provider duplicate <profile>', success: false };
78
89
  return buildProviderDuplicate(ui, profileArg, options);
79
90
  }
@@ -87,12 +98,19 @@ export async function executeProviderCommand(
87
98
  if (!profileArg) return { message: 'Usage: provider delete <profile>', success: false };
88
99
  const preConfirmed = argTokens.includes('--confirmed');
89
100
  if (!preConfirmed && ui) {
90
- const response = await ui.ask(confirmAction('provider-delete', `Delete profile "${profileArg}"?`));
101
+ const response = await ui.ask(
102
+ confirmAction('provider-delete', `Delete profile "${profileArg}"?`),
103
+ );
91
104
  if (!isConfirmed(response)) {
92
105
  return { message: 'Provider delete cancelled.', success: true };
93
106
  }
94
107
  }
95
- return buildProviderProfileDelete(settings.providers, settings.currentProvider, profileArg, options);
108
+ return buildProviderProfileDelete(
109
+ settings.providers,
110
+ settings.currentProvider,
111
+ profileArg,
112
+ options,
113
+ );
96
114
  }
97
115
  if (subcommand === 'add') {
98
116
  return buildProviderSetup(ui, profileArg, options, isSetupRequired);
@@ -100,7 +118,7 @@ export async function executeProviderCommand(
100
118
 
101
119
  return {
102
120
  message:
103
- 'Usage: provider [current|list|switch <profile>|edit <profile>|duplicate <profile>|delete <profile>|add <type>|test [profile]]',
121
+ 'Usage: provider [current|list|switch <profile>|edit <profile>|reconnect <profile>|duplicate <profile>|delete <profile>|add <type>|test [profile]]',
104
122
  success: false,
105
123
  };
106
124
  }
@@ -15,6 +15,17 @@ function buildProviderSubcommands(): ICommand[] {
15
15
  { name: 'list', description: 'List provider profiles', source: 'provider' },
16
16
  { name: 'switch', description: 'Hot-swap to another provider profile', source: 'provider' },
17
17
  { name: 'add', description: 'Configure a provider profile', source: 'provider' },
18
+ {
19
+ name: 'edit',
20
+ description: 'Edit a profile, including optional server credentials',
21
+ source: 'provider',
22
+ },
23
+ {
24
+ name: 'reconnect',
25
+ description: 'Replace a named service connection through browser approval or API key entry',
26
+ source: 'provider',
27
+ modelInvocable: false,
28
+ },
18
29
  { name: 'test', description: 'Test provider profile', source: 'provider' },
19
30
  ];
20
31
  }
@@ -23,11 +34,13 @@ export function createProviderCommandEntry(): ICommand {
23
34
  return {
24
35
  name: 'provider',
25
36
  displayName: 'Provider Setup',
26
- description: 'Manage provider profiles',
37
+ description:
38
+ 'Show, configure or switch provider profiles. Suggest /provider reconnect <profile> when a saved service connection needs new credentials, or /provider edit <profile> for its model or server settings; returns profile details or the result of the change.',
27
39
  source: 'provider',
28
40
  // User-only: provider profiles hold account credentials; a credential action.
29
41
  modelInvocable: false,
30
- argumentHint: 'current | list | switch <profile> | add [type] | test [profile]',
42
+ argumentHint:
43
+ 'current | list | switch <profile> | add [type] | edit <profile> | reconnect <profile> | test [profile]',
31
44
  subcommands: buildProviderSubcommands(),
32
45
  example: '/provider switch production',
33
46
  };
@@ -4,11 +4,15 @@ import {
4
4
  isApiKeyPlaintext,
5
5
  setCurrentProvider,
6
6
  upsertProviderProfile,
7
- validateProviderProfile,
8
7
  } from '@robota-sdk/agent-framework';
9
8
 
10
9
  import { runProviderSetupAsk } from './provider-command-setup.js';
11
10
  import { createProviderSetupFlow } from './provider-setup-flow.js';
11
+ import {
12
+ hasProviderConnectionMethods,
13
+ runProviderConnectionSetup,
14
+ } from './provider-connection-setup.js';
15
+ import { validateProviderConnection } from './provider-connection-validation.js';
12
16
 
13
17
  import type { IUserInteraction } from '@robota-sdk/agent-core';
14
18
  import type {
@@ -27,11 +31,11 @@ export function formatProviderChoiceLabel(
27
31
  return `${marker}${name}: ${profile.type ?? 'unknown'} ${profile.model ?? '(no model)'}`;
28
32
  }
29
33
 
30
- export function buildProviderSwitch(
34
+ export async function buildProviderSwitch(
31
35
  providers: Record<string, IProviderProfileSettings> | undefined,
32
36
  profileName: string | undefined,
33
37
  options: IProviderCommandModuleOptions,
34
- ): ICommandResult {
38
+ ): Promise<ICommandResult> {
35
39
  if (!profileName) {
36
40
  return { message: 'Usage: /provider switch <profile>', success: false };
37
41
  }
@@ -48,7 +52,8 @@ export function buildProviderSwitch(
48
52
  success: false,
49
53
  };
50
54
  }
51
- if (options.settings.readMergedSettings().currentProvider === profileName) {
55
+ const initial = options.settings.readMergedSettings();
56
+ if (initial.currentProvider === profileName) {
52
57
  return { message: `Already using provider "${profileName}".`, success: true };
53
58
  }
54
59
  const profile = providers[profileName];
@@ -58,10 +63,7 @@ export function buildProviderSwitch(
58
63
  // previous behavior wrote `currentProvider` unconditionally and let a downstream hot-swap failure
59
64
  // (e.g. "Unknown provider: anthropic. Currently supported: ", an empty list) stand uncorrected.
60
65
  try {
61
- validateProviderProfile(profileName, profile, {
62
- providerDefinitions: options.providerDefinitions,
63
- ...(options.env === undefined ? {} : { env: options.env }),
64
- });
66
+ await validateProviderConnection(profileName, profile, options);
65
67
  } catch (error) {
66
68
  return {
67
69
  message: `Failed to switch to "${profileName}": ${error instanceof Error ? error.message : String(error)}`,
@@ -70,6 +72,15 @@ export function buildProviderSwitch(
70
72
  }
71
73
  const target = options.settings.readTargetSettings();
72
74
  const merged = options.settings.readMergedSettings();
75
+ if (
76
+ merged.currentProvider !== initial.currentProvider ||
77
+ JSON.stringify(merged.providers?.[profileName]) !== JSON.stringify(profile)
78
+ ) {
79
+ return {
80
+ success: false,
81
+ message: 'Provider settings changed while switching. Run /provider switch again.',
82
+ };
83
+ }
73
84
  const next =
74
85
  target.providers?.[profileName] !== undefined || merged.providers?.[profileName] !== undefined
75
86
  ? { ...target, currentProvider: profileName }
@@ -99,10 +110,14 @@ export async function buildProviderEdit(
99
110
  let flow;
100
111
  try {
101
112
  flow = createProviderSetupFlow(profile.type, options.providerDefinitions, {
113
+ includeEditOnlySteps: true,
102
114
  profileName,
103
115
  setCurrent: false,
104
116
  initialValues: getProviderProfileSetupValues(profile),
105
117
  });
118
+ if (hasProviderConnectionMethods(profile.type, options)) {
119
+ flow = { ...flow, steps: flow.steps.filter((step) => step.key !== 'apiKey') };
120
+ }
106
121
  } catch (error) {
107
122
  return { message: error instanceof Error ? error.message : String(error), success: false };
108
123
  }
@@ -114,6 +129,29 @@ export async function buildProviderEdit(
114
129
  );
115
130
  }
116
131
 
132
+ /** Explicitly replace this profile's connection; ordinary editing preserves its credential. */
133
+ export async function buildProviderReconnect(
134
+ ui: IUserInteraction,
135
+ profileName: string,
136
+ options: IProviderCommandModuleOptions,
137
+ ): Promise<ICommandResult> {
138
+ const profile = options.settings.readMergedSettings().providers?.[profileName];
139
+ if (!profile?.type)
140
+ return { success: false, message: `Provider profile "${profileName}" was not found.` };
141
+ if (!hasProviderConnectionMethods(profile.type, options)) {
142
+ return {
143
+ success: false,
144
+ message: `Use /provider edit ${profileName} to update this provider's credentials.`,
145
+ };
146
+ }
147
+ const flow = createProviderSetupFlow(profile.type, options.providerDefinitions, {
148
+ profileName,
149
+ setCurrent: false,
150
+ initialValues: { ...(profile.model === undefined ? {} : { model: profile.model }) },
151
+ });
152
+ return runProviderConnectionSetup(ui, flow, options, false, true);
153
+ }
154
+
117
155
  function getProviderProfileSetupValues(profile: IProviderProfileSettings): {
118
156
  model?: string;
119
157
  apiKey?: string;
@@ -147,10 +185,21 @@ function completeProviderEdit(
147
185
  };
148
186
  }
149
187
  const target = options.settings.readTargetSettings();
150
- const patch = buildProviderSetupPatch(input, {
151
- providerDefinitions: options.providerDefinitions,
152
- ...(options.env === undefined ? {} : { env: options.env }),
153
- });
188
+ const patch = buildProviderSetupPatch(
189
+ {
190
+ ...input,
191
+ ...(input.apiKey === undefined && currentProfile.apiKeyRef !== undefined
192
+ ? { apiKeyRef: currentProfile.apiKeyRef }
193
+ : {}),
194
+ ...(input.apiKey === undefined && currentProfile.apiKey !== undefined
195
+ ? { apiKey: currentProfile.apiKey }
196
+ : {}),
197
+ },
198
+ {
199
+ providerDefinitions: options.providerDefinitions,
200
+ ...(options.env === undefined ? {} : { env: options.env }),
201
+ },
202
+ );
154
203
  const updatedProfile = patch.providers[profileName];
155
204
  if (!updatedProfile) {
156
205
  return { message: `Provider profile "${profileName}" was not updated.`, success: false };
@@ -7,9 +7,11 @@ import {
7
7
  } from './provider-command-profile-lifecycle.js';
8
8
  import {
9
9
  buildProviderEdit,
10
+ buildProviderReconnect,
10
11
  buildProviderSwitch,
11
12
  formatProviderChoiceLabel,
12
13
  } from './provider-command-profile-operations.js';
14
+ import { hasProviderConnectionMethods } from './provider-connection-setup.js';
13
15
 
14
16
  import type { IUserInteraction } from '@robota-sdk/agent-core';
15
17
  import type {
@@ -23,6 +25,7 @@ const ACTION_EDIT = 'edit';
23
25
  const ACTION_TEST = 'test';
24
26
  const ACTION_DUPLICATE = 'duplicate';
25
27
  const ACTION_DELETE = 'delete';
28
+ const ACTION_RECONNECT = 'reconnect';
26
29
 
27
30
  /**
28
31
  * Ask the user to pick a provider profile, then drive its action menu (CMD-004 inline ask). Replaces
@@ -68,6 +71,9 @@ async function askProviderProfileAction(
68
71
  selectAction('provider-profile-action', `Provider profile: ${profileName}`, [
69
72
  { value: ACTION_SWITCH, label: 'Switch' },
70
73
  { value: ACTION_EDIT, label: 'Edit' },
74
+ ...(hasProviderConnectionMethods(settings.providers[profileName]?.type ?? '', options)
75
+ ? [{ value: ACTION_RECONNECT, label: 'Reconnect' }]
76
+ : []),
71
77
  { value: ACTION_TEST, label: 'Test' },
72
78
  { value: ACTION_DUPLICATE, label: 'Duplicate' },
73
79
  { value: ACTION_DELETE, label: 'Delete' },
@@ -91,6 +97,8 @@ async function executeProviderProfileAction(
91
97
  return buildProviderSwitch(settings.providers, profileName, options);
92
98
  case ACTION_EDIT:
93
99
  return buildProviderEdit(ui, profileName, options);
100
+ case ACTION_RECONNECT:
101
+ return buildProviderReconnect(ui, profileName, options);
94
102
  case ACTION_TEST:
95
103
  return testProviderProfileCommand(
96
104
  settings.currentProvider,
@@ -1,21 +1,22 @@
1
- import { textAction } from '@robota-sdk/agent-core';
2
1
  import { buildProviderSetupPatch, mergeProviderPatch } from '@robota-sdk/agent-framework';
3
2
 
3
+ import { createProviderSetupFlow } from './provider-setup-flow.js';
4
+ import { runProviderSetupAsk } from './provider-setup-ask.js';
4
5
  import {
5
- createProviderSetupFlow,
6
- formatProviderSetupHelpLinks,
7
- getProviderSetupStep,
8
- submitProviderSetupValue,
9
- } from './provider-setup-flow.js';
6
+ hasProviderConnectionMethods,
7
+ runProviderConnectionSetup,
8
+ } from './provider-connection-setup.js';
10
9
 
11
10
  import type { IProviderSetupFlowState } from './provider-setup-flow.js';
12
- import type { IActionRequest, IUserInteraction } from '@robota-sdk/agent-core';
11
+ import type { IUserInteraction } from '@robota-sdk/agent-core';
13
12
  import type {
14
13
  IProviderCommandModuleOptions,
15
14
  IProviderSetupInput,
16
15
  } from '@robota-sdk/agent-framework';
17
16
  import type { ICommandResult } from '@robota-sdk/agent-interface-command';
18
17
 
18
+ export { runProviderSetupAsk } from './provider-setup-ask.js';
19
+
19
20
  const PROVIDER_RESTART_ACTION = {
20
21
  type: 'session-restart',
21
22
  reason: 'other',
@@ -30,58 +31,6 @@ export function createSetupFlow(
30
31
  });
31
32
  }
32
33
 
33
- /** Build the per-step `IActionRequest` for the current setup step (CMD-004 inline ask). */
34
- function toProviderSetupStepRequest(
35
- flow: IProviderSetupFlowState,
36
- errorMessage?: string,
37
- ): IActionRequest {
38
- const step = getProviderSetupStep(flow);
39
- const placeholder =
40
- step.masked === true && step.defaultValue !== undefined ? '(unchanged)' : step.defaultValue;
41
- const helpLinks = formatProviderSetupHelpLinks(flow.setupHelpLinks);
42
- const description =
43
- [errorMessage, helpLinks.length > 0 ? helpLinks : undefined]
44
- .filter((part): part is string => part !== undefined && part.length > 0)
45
- .join('\n') || undefined;
46
- return textAction(`provider-setup-${step.key}`, step.title, {
47
- description,
48
- placeholder,
49
- allowEmpty: step.defaultValue !== undefined,
50
- masked: step.masked,
51
- });
52
- }
53
-
54
- /**
55
- * Drive the setup-step engine through inline `ui.ask` calls (CMD-004), re-asking the same step when a
56
- * step fails validation (the error is surfaced in the request description). The `complete` sink differs
57
- * between the add and edit paths.
58
- */
59
- export async function runProviderSetupAsk(
60
- ui: IUserInteraction,
61
- flow: IProviderSetupFlowState,
62
- complete: (input: IProviderSetupInput) => ICommandResult,
63
- cancelMessage: string,
64
- ): Promise<ICommandResult> {
65
- let state = flow;
66
- let errorMessage: string | undefined;
67
- for (;;) {
68
- const response = await ui.ask(toProviderSetupStepRequest(state, errorMessage));
69
- if (response.type === 'cancelled') {
70
- return { message: cancelMessage, success: true };
71
- }
72
- const result = submitProviderSetupValue(state, response.text ?? '');
73
- if (result.status === 'error') {
74
- errorMessage = result.message;
75
- continue;
76
- }
77
- if (result.status === 'complete') {
78
- return complete(result.input);
79
- }
80
- state = result.state;
81
- errorMessage = undefined;
82
- }
83
- }
84
-
85
34
  /** Run the `/provider add` setup wizard inline. */
86
35
  export function runProviderAddSetup(
87
36
  ui: IUserInteraction,
@@ -89,6 +38,9 @@ export function runProviderAddSetup(
89
38
  options: IProviderCommandModuleOptions,
90
39
  isSetupRequired: boolean,
91
40
  ): Promise<ICommandResult> {
41
+ if (hasProviderConnectionMethods(flow.type, options)) {
42
+ return runProviderConnectionSetup(ui, flow, options, isSetupRequired);
43
+ }
92
44
  return runProviderSetupAsk(
93
45
  ui,
94
46
  flow,
@@ -0,0 +1,287 @@
1
+ import {
2
+ ENV_REFERENCE_PREFIX,
3
+ findProviderDefinition,
4
+ isEnvReference,
5
+ selectAction,
6
+ textAction,
7
+ } from '@robota-sdk/agent-core';
8
+ import {
9
+ buildProviderSetupPatch,
10
+ formatOrgPolicyViolationMessage,
11
+ mergeProviderPatch,
12
+ suggestProviderProfileName,
13
+ } from '@robota-sdk/agent-framework';
14
+
15
+ import { runProviderSetupAsk } from './provider-setup-ask.js';
16
+
17
+ import type { ICredentialKey, IUserInteraction } from '@robota-sdk/agent-core';
18
+ import type {
19
+ IProviderCommandModuleOptions,
20
+ IProviderSetupInput,
21
+ TProviderConnectionStage,
22
+ } from '@robota-sdk/agent-framework';
23
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
24
+ import type { IProviderSetupFlowState } from './provider-setup-flow.js';
25
+
26
+ let nextAttempt = 0;
27
+ const STAGE_LABELS: Record<TProviderConnectionStage, string> = {
28
+ 'awaiting-approval': 'Waiting for browser approval',
29
+ exchanging: 'Completing browser authentication',
30
+ validating: 'Checking the connection',
31
+ saving: 'Finishing connection setup',
32
+ };
33
+
34
+ export function hasProviderConnectionMethods(
35
+ type: string,
36
+ options: IProviderCommandModuleOptions,
37
+ ): boolean {
38
+ return (
39
+ (findProviderDefinition(options.providerDefinitions, type)?.connectionMethods?.length ?? 0) > 0
40
+ );
41
+ }
42
+
43
+ /** Both frontends acquire new keys through the host; settings retain references to stored keys. */
44
+ export async function runProviderConnectionSetup(
45
+ ui: IUserInteraction,
46
+ flow: IProviderSetupFlowState,
47
+ options: IProviderCommandModuleOptions,
48
+ isSetupRequired: boolean,
49
+ replace = false,
50
+ ): Promise<ICommandResult> {
51
+ const definition = findProviderDefinition(options.providerDefinitions, flow.type);
52
+ const profile =
53
+ flow.profileName ??
54
+ suggestProviderProfileName(
55
+ { type: flow.type },
56
+ { existingProfileNames: flow.existingProfileNames },
57
+ );
58
+ const initial = options.settings.readMergedSettings();
59
+ const previous = initial.providers?.[profile];
60
+ const previousSnapshot = JSON.stringify(previous);
61
+ if (
62
+ options.orgPolicy?.allowedProviders &&
63
+ !options.orgPolicy.allowedProviders.includes(profile)
64
+ ) {
65
+ return {
66
+ success: false,
67
+ message: formatOrgPolicyViolationMessage(
68
+ `Provider profile "${profile}" is not allowed by your organization policy.`,
69
+ options.orgPolicy.adminContact,
70
+ ),
71
+ };
72
+ }
73
+ const methods = (definition?.connectionMethods ?? []).filter(
74
+ (method) => options.orgPolicy?.requireApiKeyFromEnv !== true || method === 'api-key',
75
+ );
76
+ let description = options.orgPolicy?.requireApiKeyFromEnv
77
+ ? formatOrgPolicyViolationMessage(
78
+ 'Your organization requires an environment reference such as $ENV:OPENROUTER_API_KEY.',
79
+ options.orgPolicy.adminContact,
80
+ )
81
+ : undefined;
82
+ for (;;) {
83
+ const methodAnswer = await ui.ask(
84
+ selectAction(
85
+ 'provider-connection-method',
86
+ `Connect with ${definition?.displayName ?? flow.type}`,
87
+ methods.map((method) => ({
88
+ value: method,
89
+ label: method === 'api-key' ? 'API key' : 'Browser',
90
+ })),
91
+ { description },
92
+ ),
93
+ );
94
+ if (methodAnswer.type !== 'answer') return cancelled();
95
+ const method = methodAnswer.values[0];
96
+ if ((method !== 'api-key' && method !== 'browser') || !methods.includes(method))
97
+ return cancelled();
98
+ let apiKey: string | undefined;
99
+ if (method === 'api-key') {
100
+ const keyAnswer = await ui.ask(
101
+ textAction('provider-connection-key', 'API key or $ENV:VARIABLE_NAME', {
102
+ masked: true,
103
+ allowEmpty: false,
104
+ }),
105
+ );
106
+ if (keyAnswer.type !== 'answer') return cancelled();
107
+ apiKey = keyAnswer.text?.trim();
108
+ if (!apiKey || (options.orgPolicy?.requireApiKeyFromEnv && !isEnvReference(apiKey))) {
109
+ description = options.orgPolicy?.requireApiKeyFromEnv
110
+ ? formatOrgPolicyViolationMessage(
111
+ 'Enter an environment reference such as $ENV:OPENROUTER_API_KEY.',
112
+ options.orgPolicy.adminContact,
113
+ )
114
+ : 'An API key is required.';
115
+ continue;
116
+ }
117
+ if (isEnvReference(apiKey)) {
118
+ const variable = apiKey.slice(ENV_REFERENCE_PREFIX.length).trim();
119
+ if (!variable || !(options.env ?? process.env)[variable]?.trim()) {
120
+ description =
121
+ 'The environment reference is unset or invalid. Set its variable, then choose API key to try again.';
122
+ continue;
123
+ }
124
+ }
125
+ }
126
+ const controller = new AbortController();
127
+ let progress: AbortController | undefined;
128
+ let sequence = 0;
129
+ const attempt = ++nextAttempt;
130
+ const stopProgress = (): void => {
131
+ progress?.abort();
132
+ progress = undefined;
133
+ };
134
+ const onProgress = (stage: TProviderConnectionStage): void => {
135
+ stopProgress();
136
+ const prompt = new AbortController();
137
+ progress = prompt;
138
+ void ui
139
+ .ask(
140
+ selectAction(
141
+ `provider-connection-progress-${attempt}-${++sequence}`,
142
+ STAGE_LABELS[stage],
143
+ [{ value: 'cancel', label: 'Cancel connection' }],
144
+ { description: 'Keep this session open. You can cancel and choose API key setup.' },
145
+ ),
146
+ { signal: prompt.signal },
147
+ )
148
+ .then((response) => {
149
+ if (response.type === 'answer' || !prompt.signal.aborted) controller.abort();
150
+ })
151
+ .catch(() => {
152
+ if (!prompt.signal.aborted) controller.abort();
153
+ });
154
+ };
155
+ let result: ICommandResult | undefined;
156
+ const persist = async (reference?: ICredentialKey, hostSignal?: AbortSignal): Promise<void> => {
157
+ stopProgress();
158
+ const signal = hostSignal ?? controller.signal;
159
+ let input: IProviderSetupInput | undefined;
160
+ const modelFlow = {
161
+ ...flow,
162
+ profileName: profile,
163
+ steps: flow.steps.filter((step) => step.key !== 'apiKey' && step.key !== 'baseURL'),
164
+ };
165
+ if (modelFlow.steps.length === 0) {
166
+ input = {
167
+ profile,
168
+ type: flow.type,
169
+ model: definition?.defaults?.model,
170
+ setCurrent: flow.setCurrent ?? true,
171
+ };
172
+ } else {
173
+ await runProviderSetupAsk(
174
+ ui,
175
+ modelFlow,
176
+ (completed) => {
177
+ input = completed;
178
+ return { success: true, message: '' };
179
+ },
180
+ 'Provider connection cancelled.',
181
+ signal,
182
+ );
183
+ }
184
+ if (!input || signal.aborted || controller.signal.aborted) {
185
+ controller.abort();
186
+ throw new Error('Provider connection cancelled.');
187
+ }
188
+ const current = options.settings.readMergedSettings();
189
+ if (
190
+ JSON.stringify(current.providers?.[profile]) !== previousSnapshot ||
191
+ current.currentProvider !== initial.currentProvider
192
+ ) {
193
+ throw new Error('Provider connection changed while setup was pending.');
194
+ }
195
+ const credential =
196
+ reference === undefined
197
+ ? { apiKeyEnv: apiKey?.slice(ENV_REFERENCE_PREFIX.length).trim() }
198
+ : { apiKeyRef: reference };
199
+ const patch = buildProviderSetupPatch(
200
+ { ...input, ...credential },
201
+ {
202
+ providerDefinitions: options.providerDefinitions,
203
+ env: options.env,
204
+ },
205
+ );
206
+ if (replace && previous !== undefined) {
207
+ const retained = { ...previous };
208
+ delete retained.apiKey;
209
+ delete retained.apiKeyRef;
210
+ patch.providers[profile] = { ...retained, ...patch.providers[profile] };
211
+ }
212
+ options.settings.writeTargetSettings(
213
+ mergeProviderPatch(options.settings.readTargetSettings(), patch),
214
+ );
215
+ const active = initial.currentProvider === profile;
216
+ result = {
217
+ success: true,
218
+ message: `Provider ${profile} connected.`,
219
+ ...(replace
220
+ ? active
221
+ ? {
222
+ hostActions: [
223
+ {
224
+ type: 'session-restart',
225
+ reason: 'other',
226
+ message: 'Provider connection restart',
227
+ } as const,
228
+ ],
229
+ }
230
+ : {}
231
+ : {
232
+ hostActions: isSetupRequired
233
+ ? [{ type: 'provider-hot-swap', profileName: profile } as const]
234
+ : [
235
+ {
236
+ type: 'session-restart',
237
+ reason: 'other',
238
+ message: 'Provider setup restart',
239
+ } as const,
240
+ ],
241
+ }),
242
+ };
243
+ };
244
+ try {
245
+ if (apiKey !== undefined && isEnvReference(apiKey)) {
246
+ await persist();
247
+ } else if (options.connectionHost === undefined) {
248
+ description =
249
+ 'Host connection support is unavailable. Choose API key and use an environment reference.';
250
+ continue;
251
+ } else {
252
+ await options.connectionHost.connect(
253
+ {
254
+ type: flow.type,
255
+ profile,
256
+ method,
257
+ ...(apiKey === undefined ? {} : { apiKey }),
258
+ signal: controller.signal,
259
+ ...(method === 'browser' ? { onProgress } : {}),
260
+ },
261
+ persist,
262
+ );
263
+ }
264
+ if (result === undefined) throw new Error('The host did not save a connection.');
265
+ return result;
266
+ } catch {
267
+ const recovery =
268
+ method === 'browser' && flow.type === 'openrouter'
269
+ ? ' If OpenRouter issued a key during this attempt, it may remain in your account. Review OpenRouter Keys at https://openrouter.ai/keys.'
270
+ : '';
271
+ if (controller.signal.aborted) {
272
+ description = `Connection cancelled. Choose a connection method to try again.${recovery}`;
273
+ continue;
274
+ }
275
+ return {
276
+ success: false,
277
+ message: `Connection failed. Retry with ${replace ? `/provider reconnect ${profile}` : `/provider add ${flow.type}`} and choose ${flow.type === 'openrouter' ? 'an ordinary OpenRouter API key or Browser' : 'API key or Browser'}.${recovery}`,
278
+ };
279
+ } finally {
280
+ stopProgress();
281
+ }
282
+ }
283
+ }
284
+
285
+ function cancelled(): ICommandResult {
286
+ return { success: true, message: 'Provider connection cancelled.' };
287
+ }