@robota-sdk/agent-command 3.0.0-beta.76 → 3.0.0-beta.77

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 (53) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +12 -6
  3. package/dist/node/index.cjs +38 -33
  4. package/dist/node/index.d.ts +68 -6
  5. package/dist/node/index.d.ts.map +1 -1
  6. package/dist/node/index.js +40 -35
  7. package/dist/node/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/agent/agent-command-parser.ts +1 -1
  10. package/src/agent/agent-command.ts +2 -1
  11. package/src/background/__tests__/background-command-module.test.ts +2 -5
  12. package/src/default/__tests__/default-command-modules.test.ts +5 -2
  13. package/src/default/default-command-modules.ts +6 -0
  14. package/src/editor/__tests__/editor-command-functional.test.ts +91 -0
  15. package/src/editor/editor-command-module.ts +47 -0
  16. package/src/editor/editor-command.ts +53 -0
  17. package/src/editor/index.ts +7 -0
  18. package/src/editor/resolve-editor.ts +21 -0
  19. package/src/exit/__tests__/exit-command-module.test.ts +21 -2
  20. package/src/exit/exit-command-module.ts +1 -10
  21. package/src/exit/exit-command.ts +15 -1
  22. package/src/goal/__tests__/goal-command.test.ts +75 -0
  23. package/src/goal/goal-command-module.ts +48 -0
  24. package/src/goal/goal-command.ts +70 -0
  25. package/src/goal/index.ts +6 -0
  26. package/src/index.ts +3 -0
  27. package/src/language/__tests__/language-command-module.test.ts +16 -0
  28. package/src/language/language-command-module.ts +1 -18
  29. package/src/language/language-command.ts +35 -9
  30. package/src/mode/__tests__/mode-command-module.test.ts +34 -0
  31. package/src/mode/mode-command-module.ts +1 -18
  32. package/src/mode/mode-command.ts +31 -8
  33. package/src/preset/__tests__/preset-command-module.test.ts +36 -0
  34. package/src/preset/preset-command-module.ts +1 -18
  35. package/src/preset/preset-command.ts +37 -8
  36. package/src/provider/__tests__/org-policy.test.ts +19 -13
  37. package/src/provider/__tests__/provider-command-module.test.ts +151 -80
  38. package/src/provider/__tests__/scripted-interaction.ts +28 -0
  39. package/src/provider/provider-command-execution.ts +67 -50
  40. package/src/provider/provider-command-module.ts +3 -19
  41. package/src/provider/provider-command-profile-lifecycle.ts +52 -72
  42. package/src/provider/provider-command-profile-operations.ts +15 -51
  43. package/src/provider/provider-command-profile.ts +44 -49
  44. package/src/provider/provider-command-setup.ts +56 -51
  45. package/src/session/__tests__/session-command-module.test.ts +38 -0
  46. package/src/session/session-command-module.ts +1 -10
  47. package/src/session/session-command.ts +14 -1
  48. package/src/shell/__tests__/shell-command-functional.test.ts +96 -0
  49. package/src/shell/index.ts +8 -0
  50. package/src/shell/resolve-shell.ts +25 -0
  51. package/src/shell/shell-command-module.ts +47 -0
  52. package/src/shell/shell-command.ts +44 -0
  53. package/src/shell/spawn-inherited.ts +32 -0
@@ -7,6 +7,7 @@ import type {
7
7
  TProviderSettingsDocument,
8
8
  } from '@robota-sdk/agent-framework';
9
9
  import { createProviderCommandModule } from '../provider-command-module.js';
10
+ import { scriptedContext } from './scripted-interaction.js';
10
11
 
11
12
  const providerDefinitions: readonly IProviderDefinition[] = [
12
13
  {
@@ -93,7 +94,8 @@ function createExecutor(adapter: IProviderCommandSettingsAdapter): SystemCommand
93
94
  return new SystemCommandExecutor([...(module.systemCommands ?? [])]);
94
95
  }
95
96
 
96
- const session = {} as ICommandHostContext;
97
+ /** Context with no interactive renderer attached (headless/automation). */
98
+ const headlessContext = {} as ICommandHostContext;
97
99
 
98
100
  describe('createProviderCommandModule', () => {
99
101
  it('contributes /provider metadata and executable command from the provider package', () => {
@@ -107,7 +109,7 @@ describe('createProviderCommandModule', () => {
107
109
  expect(module.systemCommands?.map((command) => command.name)).toEqual(['provider']);
108
110
  });
109
111
 
110
- it('lists provider profiles from injected merged settings', async () => {
112
+ it('asks the user to pick a provider profile from injected merged settings', async () => {
111
113
  const { adapter } = createSettingsAdapter({
112
114
  currentProvider: 'openai',
113
115
  providers: {
@@ -116,18 +118,21 @@ describe('createProviderCommandModule', () => {
116
118
  },
117
119
  });
118
120
 
119
- const result = await createExecutor(adapter).execute('provider', session, 'list');
121
+ const { context, requests } = scriptedContext([{ type: 'cancelled' }]);
122
+ const result = await createExecutor(adapter).execute('provider', context, 'list');
120
123
 
121
124
  expect(result?.success).toBe(true);
122
- expect(result?.message).toContain('* openai');
123
- expect(result?.message).toContain('anthropic');
124
- expect(result?.interaction?.prompt).toMatchObject({
125
- kind: 'choice',
126
- title: 'Select provider profile',
127
- });
125
+ const picker = requests[0];
126
+ expect(picker?.title).toBe('Select provider profile');
127
+ expect(picker?.options?.map((option) => option.label)).toEqual(
128
+ expect.arrayContaining([
129
+ expect.stringContaining('* openai'),
130
+ expect.stringContaining('anthropic'),
131
+ ]),
132
+ );
128
133
  });
129
134
 
130
- it('opens a provider profile picker from /provider without CLI-owned routing', async () => {
135
+ it('returns a plain text list when no interactive renderer is attached', async () => {
131
136
  const { adapter } = createSettingsAdapter({
132
137
  currentProvider: 'openai',
133
138
  providers: {
@@ -136,14 +141,31 @@ describe('createProviderCommandModule', () => {
136
141
  },
137
142
  });
138
143
 
139
- const result = await createExecutor(adapter).execute('provider', session, '');
140
- const selected = await result?.interaction?.submit('anthropic');
144
+ const result = await createExecutor(adapter).execute('provider', headlessContext, 'list');
141
145
 
146
+ expect(result?.success).toBe(true);
142
147
  expect(result?.message).toContain('* openai');
143
- expect(selected?.interaction?.prompt).toMatchObject({
144
- kind: 'choice',
145
- title: 'Provider profile: anthropic',
148
+ expect(result?.message).toContain('anthropic');
149
+ });
150
+
151
+ it('opens a provider profile action menu from /provider after picking a profile', async () => {
152
+ const { adapter } = createSettingsAdapter({
153
+ currentProvider: 'openai',
154
+ providers: {
155
+ openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
156
+ anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
157
+ },
146
158
  });
159
+
160
+ const { context, requests } = scriptedContext([
161
+ { type: 'answer', values: ['anthropic'] },
162
+ { type: 'cancelled' },
163
+ ]);
164
+ const result = await createExecutor(adapter).execute('provider', context, '');
165
+
166
+ expect(result?.success).toBe(true);
167
+ expect(requests[0]?.title).toBe('Select provider profile');
168
+ expect(requests[1]?.title).toBe('Provider profile: anthropic');
147
169
  });
148
170
 
149
171
  it('switches provider immediately via /provider switch without confirmation dialog', async () => {
@@ -158,9 +180,12 @@ describe('createProviderCommandModule', () => {
158
180
  {},
159
181
  );
160
182
 
161
- const result = await createExecutor(adapter).execute('provider', session, 'switch openai');
183
+ const result = await createExecutor(adapter).execute(
184
+ 'provider',
185
+ headlessContext,
186
+ 'switch openai',
187
+ );
162
188
 
163
- expect(result?.interaction).toBeUndefined();
164
189
  expect(result?.message).toBe(
165
190
  'Switched to openai (supergemma4-26b-uncensored-v2). History preserved.',
166
191
  );
@@ -182,16 +207,17 @@ describe('createProviderCommandModule', () => {
182
207
  {},
183
208
  );
184
209
 
185
- const listed = await createExecutor(adapter).execute('provider', session, 'list');
186
- const selected = await listed?.interaction?.submit('openai');
187
- const switchRequested = await selected?.interaction?.submit('switch');
210
+ const { context } = scriptedContext([
211
+ { type: 'answer', values: ['openai'] },
212
+ { type: 'answer', values: ['switch'] },
213
+ ]);
214
+ const result = await createExecutor(adapter).execute('provider', context, 'list');
188
215
 
189
- expect(switchRequested?.message).toBe(
216
+ expect(result?.message).toBe(
190
217
  'Switched to openai (supergemma4-26b-uncensored-v2). History preserved.',
191
218
  );
192
- expect(switchRequested?.interaction).toBeUndefined();
193
219
  expect(readTarget().currentProvider).toBe('openai');
194
- expect(switchRequested?.effects).toEqual([
220
+ expect(result?.effects).toEqual([
195
221
  { type: 'provider-hot-swap-requested', profileName: 'openai' },
196
222
  ]);
197
223
  });
@@ -211,19 +237,19 @@ describe('createProviderCommandModule', () => {
211
237
  {},
212
238
  );
213
239
 
214
- const listed = await createExecutor(adapter).execute('provider', session, 'list');
215
- const selected = await listed?.interaction?.submit('anthropic');
216
- const editRequested = await selected?.interaction?.submit('edit');
217
-
218
- expect(editRequested?.interaction?.prompt).toMatchObject({
219
- kind: 'text',
220
- title: 'anthropic API key',
221
- placeholder: '(unchanged)',
222
- masked: true,
223
- });
240
+ const { context, requests } = scriptedContext([
241
+ { type: 'answer', values: ['anthropic'] },
242
+ { type: 'answer', values: ['edit'] },
243
+ { type: 'answer', values: [], text: '' },
244
+ { type: 'answer', values: [], text: 'claude-opus-4-5' },
245
+ ]);
246
+ const completed = await createExecutor(adapter).execute('provider', context, 'list');
224
247
 
225
- const modelPrompt = await editRequested?.interaction?.submit('');
226
- const completed = await modelPrompt?.interaction?.submit('claude-opus-4-5');
248
+ const apiKeyRequest = requests[2];
249
+ expect(apiKeyRequest?.title).toBe('anthropic API key');
250
+ expect(apiKeyRequest?.allowFreeText).toBe(true);
251
+ expect(apiKeyRequest?.placeholder).toBe('(unchanged)');
252
+ expect(apiKeyRequest?.masked).toBe(true);
227
253
 
228
254
  expect(readTarget()).toMatchObject({
229
255
  providers: {
@@ -252,16 +278,15 @@ describe('createProviderCommandModule', () => {
252
278
  },
253
279
  });
254
280
 
255
- const listed = await createExecutor(adapter).execute('provider', session, 'list');
256
- const selected = await listed?.interaction?.submit('openai');
257
- const duplicateRequested = await selected?.interaction?.submit('duplicate');
258
- const completed = await duplicateRequested?.interaction?.submit('');
281
+ const { context, requests } = scriptedContext([
282
+ { type: 'answer', values: ['openai'] },
283
+ { type: 'answer', values: ['duplicate'] },
284
+ { type: 'answer', values: [], text: '' },
285
+ ]);
286
+ const completed = await createExecutor(adapter).execute('provider', context, 'list');
259
287
 
260
- expect(duplicateRequested?.interaction?.prompt).toMatchObject({
261
- kind: 'text',
262
- title: 'Duplicate openai as',
263
- placeholder: 'openai-copy',
264
- });
288
+ expect(requests[2]?.title).toBe('Duplicate openai as');
289
+ expect(requests[2]?.placeholder).toBe('openai-copy');
265
290
  expect(readTarget()).toMatchObject({
266
291
  providers: {
267
292
  'openai-copy': {
@@ -292,11 +317,16 @@ describe('createProviderCommandModule', () => {
292
317
  },
293
318
  );
294
319
 
295
- const listed = await createExecutor(adapter).execute('provider', session, 'list');
296
- const selected = await listed?.interaction?.submit('anthropic');
297
- const deleteRequested = await selected?.interaction?.submit('delete');
298
- const completed = await deleteRequested?.interaction?.submit('yes');
320
+ const { context, requests } = scriptedContext([
321
+ { type: 'answer', values: ['anthropic'] },
322
+ { type: 'answer', values: ['delete'] },
323
+ { type: 'answer', values: ['yes'] },
324
+ ]);
325
+ const completed = await createExecutor(adapter).execute('provider', context, 'list');
299
326
 
327
+ // The confirm prompt must actually be issued — guards against a silent drop of the
328
+ // confirmation step that would still "delete and pass".
329
+ expect(requests[2]?.title).toBe('Delete provider profile anthropic?');
300
330
  expect(completed?.message).toBe('Provider profile deleted: anthropic.');
301
331
  expect(readTarget()).toEqual({
302
332
  currentProvider: 'openai',
@@ -324,16 +354,17 @@ describe('createProviderCommandModule', () => {
324
354
  },
325
355
  );
326
356
 
327
- const listed = await createExecutor(adapter).execute('provider', session, 'list');
328
- const selected = await listed?.interaction?.submit('anthropic');
329
- const deleteRequested = await selected?.interaction?.submit('delete');
330
- const replacementPrompt = await deleteRequested?.interaction?.submit('yes');
331
- const completed = await replacementPrompt?.interaction?.submit('openai');
357
+ const { context, requests } = scriptedContext([
358
+ { type: 'answer', values: ['anthropic'] },
359
+ { type: 'answer', values: ['delete'] },
360
+ { type: 'answer', values: ['yes'] },
361
+ { type: 'answer', values: ['openai'] },
362
+ ]);
363
+ const completed = await createExecutor(adapter).execute('provider', context, 'list');
332
364
 
333
- expect(replacementPrompt?.interaction?.prompt).toMatchObject({
334
- kind: 'choice',
335
- title: 'Replacement provider for anthropic',
336
- });
365
+ // The confirm precedes the replacement picker — assert both so neither step can be dropped silently.
366
+ expect(requests[2]?.title).toBe('Delete provider profile anthropic?');
367
+ expect(requests[3]?.title).toBe('Replacement provider for anthropic');
337
368
  expect(readTarget()).toEqual({
338
369
  currentProvider: 'openai',
339
370
  providers: {
@@ -361,30 +392,39 @@ describe('createProviderCommandModule', () => {
361
392
  {},
362
393
  );
363
394
 
364
- const listed = await createExecutor(adapter).execute('provider', session, 'list');
365
- const selected = await listed?.interaction?.submit('anthropic');
366
- const deleteRequested = await selected?.interaction?.submit('delete');
395
+ const { context } = scriptedContext([
396
+ { type: 'answer', values: ['anthropic'] },
397
+ { type: 'answer', values: ['delete'] },
398
+ ]);
399
+ const result = await createExecutor(adapter).execute('provider', context, 'list');
367
400
 
368
- expect(deleteRequested?.success).toBe(false);
369
- expect(deleteRequested?.message).toContain('not stored in the active write target');
401
+ expect(result?.success).toBe(false);
402
+ expect(result?.message).toContain('not stored in the active write target');
370
403
  });
371
404
 
372
405
  it('owns provider setup flow and writes settings after generic prompt submissions', async () => {
373
406
  const { adapter, readTarget } = createSettingsAdapter({}, {});
374
- const first = await createExecutor(adapter).execute('provider', session, 'add openai');
375
-
376
- expect(first?.interaction?.prompt).toMatchObject({
377
- kind: 'text',
378
- title: 'OpenAI-compatible base URL',
379
- description:
380
- ' Setup help: Official: OpenAI-compatible local server docs - https://lmstudio.ai/docs/developer',
381
- placeholder: 'http://localhost:1234/v1',
382
- allowEmpty: true,
383
- });
384
407
 
385
- const second = await first?.interaction?.submit('');
386
- const third = await second?.interaction?.submit('');
387
- const completed = await third?.interaction?.submit('');
408
+ const { context, requests } = scriptedContext([
409
+ { type: 'answer', values: [], text: '' },
410
+ { type: 'answer', values: [], text: '' },
411
+ { type: 'answer', values: [], text: '' },
412
+ ]);
413
+ const completed = await createExecutor(adapter).execute('provider', context, 'add openai');
414
+
415
+ expect(requests[0]?.title).toBe('OpenAI-compatible base URL');
416
+ expect(requests[0]?.description).toBe(
417
+ ' Setup help: Official: OpenAI-compatible local server docs - https://lmstudio.ai/docs/developer',
418
+ );
419
+ expect(requests[0]?.placeholder).toBe('http://localhost:1234/v1');
420
+ expect(requests[0]?.allowEmpty).toBe(true);
421
+ // All three setup steps must be asked in order — a dropped step would otherwise be hidden by the
422
+ // scripted double returning the surplus answers to whatever remains.
423
+ expect(requests.map((request) => request.title)).toEqual([
424
+ 'OpenAI-compatible base URL',
425
+ 'OpenAI-compatible model',
426
+ 'OpenAI-compatible API key',
427
+ ]);
388
428
 
389
429
  expect(readTarget()).toMatchObject({
390
430
  currentProvider: 'openai',
@@ -406,6 +446,35 @@ describe('createProviderCommandModule', () => {
406
446
  ]);
407
447
  });
408
448
 
449
+ it('asks the user to pick a provider type when /provider add is called without one', async () => {
450
+ const { adapter, readTarget } = createSettingsAdapter({}, {});
451
+
452
+ const { context, requests } = scriptedContext([
453
+ { type: 'answer', values: ['openai'] },
454
+ { type: 'answer', values: [], text: '' },
455
+ { type: 'answer', values: [], text: '' },
456
+ { type: 'answer', values: [], text: '' },
457
+ ]);
458
+ const completed = await createExecutor(adapter).execute('provider', context, 'add');
459
+
460
+ expect(requests[0]?.title).toBe('Select provider');
461
+ expect(requests[0]?.options?.map((option) => option.value)).toEqual(['openai', 'anthropic']);
462
+ expect(readTarget()).toMatchObject({
463
+ currentProvider: 'openai',
464
+ providers: { openai: { type: 'openai' } },
465
+ });
466
+ expect(completed?.success).toBe(true);
467
+ });
468
+
469
+ it('reports usage for /provider add without a type when no renderer is attached', async () => {
470
+ const { adapter } = createSettingsAdapter({}, {});
471
+
472
+ const result = await createExecutor(adapter).execute('provider', headlessContext, 'add');
473
+
474
+ expect(result?.success).toBe(false);
475
+ expect(result?.message).toContain('Usage: provider add <type>');
476
+ });
477
+
409
478
  it('creates another profile when provider type already exists', async () => {
410
479
  const { adapter, readTarget } = createSettingsAdapter(
411
480
  {
@@ -421,11 +490,13 @@ describe('createProviderCommandModule', () => {
421
490
  },
422
491
  {},
423
492
  );
424
- const first = await createExecutor(adapter).execute('provider', session, 'add openai');
425
- const second = await first?.interaction?.submit('');
426
- const third = await second?.interaction?.submit('');
427
493
 
428
- await third?.interaction?.submit('');
494
+ const { context } = scriptedContext([
495
+ { type: 'answer', values: [], text: '' },
496
+ { type: 'answer', values: [], text: '' },
497
+ { type: 'answer', values: [], text: '' },
498
+ ]);
499
+ await createExecutor(adapter).execute('provider', context, 'add openai');
429
500
 
430
501
  expect(readTarget()).toMatchObject({
431
502
  currentProvider: 'openai-2',
@@ -465,7 +536,7 @@ describe('createProviderCommandModule', () => {
465
536
  });
466
537
  const result = await new SystemCommandExecutor([...(module.systemCommands ?? [])]).execute(
467
538
  'provider',
468
- session,
539
+ headlessContext,
469
540
  'test openai',
470
541
  );
471
542
 
@@ -0,0 +1,28 @@
1
+ import type { IActionRequest, IUserInteraction, TActionResponse } from '@robota-sdk/agent-core';
2
+ import type { ICommandHostContext } from '@robota-sdk/agent-framework';
3
+
4
+ /**
5
+ * Build a command host context whose `getUserInteraction().ask` replays a scripted sequence of
6
+ * responses (CMD-004 test double). Each `ask` records the request it received (so tests can assert on
7
+ * the rendered action shape) and returns the next scripted answer; once the script is exhausted it
8
+ * returns `{ type: 'cancelled' }`, mirroring a user dismissing the prompt.
9
+ */
10
+ export function scriptedContext(answers: readonly TActionResponse[]): {
11
+ context: ICommandHostContext;
12
+ requests: IActionRequest[];
13
+ } {
14
+ const requests: IActionRequest[] = [];
15
+ let index = 0;
16
+ const ui: IUserInteraction = {
17
+ ask: (request) => {
18
+ requests.push(request);
19
+ const answer = answers[index];
20
+ index += 1;
21
+ return Promise.resolve(answer ?? { type: 'cancelled' });
22
+ },
23
+ };
24
+ const context = {
25
+ getUserInteraction: () => ui,
26
+ } as Partial<ICommandHostContext> as ICommandHostContext;
27
+ return { context, requests };
28
+ }
@@ -1,30 +1,38 @@
1
- import { findProviderDefinition, formatSupportedProviderTypes } from '@robota-sdk/agent-core';
1
+ import {
2
+ findProviderDefinition,
3
+ formatSupportedProviderTypes,
4
+ selectAction,
5
+ } from '@robota-sdk/agent-core';
2
6
  import { testProviderProfileCommand } from '@robota-sdk/agent-framework';
3
7
 
4
8
  import { buildProviderSwitch } from './provider-command-profile-operations.js';
5
- import { createProviderProfileSelectionInteraction } from './provider-command-profile.js';
6
- import { createSetupFlow, createProviderSetupInteraction } from './provider-command-setup.js';
9
+ import { askProviderProfileSelection } from './provider-command-profile.js';
10
+ import { createSetupFlow, runProviderAddSetup } from './provider-command-setup.js';
7
11
  import { formatProviderSetupChoiceLabel } from './provider-setup-flow.js';
8
12
 
13
+ import type { IUserInteraction } from '@robota-sdk/agent-core';
9
14
  import type {
15
+ ICommandHostContext,
10
16
  IProviderCommandModuleOptions,
11
17
  IProviderProfileSettings,
12
18
  } from '@robota-sdk/agent-framework';
13
- import type { ICommandInteraction, ICommandResult } from '@robota-sdk/agent-interface-transport';
19
+ import type { ICommandResult } from '@robota-sdk/agent-interface-transport';
14
20
 
15
21
  export async function executeProviderCommand(
22
+ context: ICommandHostContext,
16
23
  args: string,
17
24
  options: IProviderCommandModuleOptions,
18
25
  ): Promise<ICommandResult> {
26
+ const ui = context.getUserInteraction?.();
19
27
  const settings = options.settings.readMergedSettings();
20
28
  const trimmedArgs = args.trim();
21
29
  if (trimmedArgs.length === 0) {
22
- return buildProviderProfilePicker(settings.currentProvider, settings.providers, options);
30
+ return buildProviderProfilePicker(ui, settings.currentProvider, settings.providers, options);
23
31
  }
24
32
  const [subcommand = 'current', profileArg] = trimmedArgs.split(/\s+/);
25
33
 
26
34
  if (subcommand === 'list') {
27
- return buildProviderProfilePicker(settings.currentProvider, settings.providers, options);
35
+ return buildProviderProfilePicker(ui, settings.currentProvider, settings.providers, options);
28
36
  }
29
37
  if (subcommand === 'current' || subcommand === '') {
30
38
  return {
@@ -36,7 +44,7 @@ export async function executeProviderCommand(
36
44
  return buildProviderSwitch(settings.providers, profileArg, options);
37
45
  }
38
46
  if (subcommand === 'test') {
39
- return await testProviderProfileCommand(
47
+ return testProviderProfileCommand(
40
48
  settings.currentProvider,
41
49
  settings.providers,
42
50
  profileArg,
@@ -44,7 +52,7 @@ export async function executeProviderCommand(
44
52
  );
45
53
  }
46
54
  if (subcommand === 'add') {
47
- return buildProviderSetup(profileArg, options);
55
+ return buildProviderSetup(ui, profileArg, options);
48
56
  }
49
57
 
50
58
  return {
@@ -53,20 +61,20 @@ export async function executeProviderCommand(
53
61
  };
54
62
  }
55
63
 
64
+ /**
65
+ * List the provider profiles. With an interactive renderer attached, drive the inline profile picker
66
+ * (CMD-004); without one (headless/automation) or with no profiles, return the formatted list as text.
67
+ */
56
68
  function buildProviderProfilePicker(
69
+ ui: IUserInteraction | undefined,
57
70
  currentProvider: string | undefined,
58
71
  providers: Record<string, IProviderProfileSettings> | undefined,
59
72
  options: IProviderCommandModuleOptions,
60
- ): ICommandResult {
61
- const message = formatProviderList(currentProvider, providers);
62
- if (Object.keys(providers ?? {}).length === 0) {
63
- return { message, success: true };
73
+ ): Promise<ICommandResult> | ICommandResult {
74
+ if (!ui || Object.keys(providers ?? {}).length === 0) {
75
+ return { message: formatProviderList(currentProvider, providers), success: true };
64
76
  }
65
- return {
66
- message,
67
- success: true,
68
- interaction: createProviderProfileSelectionInteraction(currentProvider, providers, options),
69
- };
77
+ return askProviderProfileSelection(ui, currentProvider, providers, options);
70
78
  }
71
79
 
72
80
  function formatProviderList(
@@ -104,16 +112,24 @@ function formatCurrentProvider(
104
112
  ].join('\n');
105
113
  }
106
114
 
115
+ /**
116
+ * Configure a provider profile. With an explicit `type`, run the setup wizard directly; without one,
117
+ * ask the user to pick a provider type first (CMD-004). Without an interactive renderer, setup cannot
118
+ * proceed — return usage text instead of a silent guess.
119
+ */
107
120
  function buildProviderSetup(
121
+ ui: IUserInteraction | undefined,
108
122
  type: string | undefined,
109
123
  options: IProviderCommandModuleOptions,
110
- ): ICommandResult {
124
+ ): Promise<ICommandResult> | ICommandResult {
111
125
  if (type === undefined || type.length === 0) {
112
- return {
113
- message: 'Provider setup requested. Select a provider to continue.',
114
- success: true,
115
- interaction: createProviderSelectionInteraction(options),
116
- };
126
+ if (!ui) {
127
+ return {
128
+ message: `Usage: provider add <type>. Supported: ${formatSupportedProviderTypes(options.providerDefinitions)}`,
129
+ success: false,
130
+ };
131
+ }
132
+ return askProviderSetupType(ui, options);
117
133
  }
118
134
  if (findProviderDefinition(options.providerDefinitions, type) === undefined) {
119
135
  return {
@@ -121,34 +137,35 @@ function buildProviderSetup(
121
137
  success: false,
122
138
  };
123
139
  }
124
- return {
125
- message: `Provider setup requested: ${type}`,
126
- success: true,
127
- interaction: createProviderSetupInteraction(createSetupFlow(type, options), options),
128
- };
140
+ if (!ui) {
141
+ return {
142
+ message: `Provider setup for "${type}" requires an interactive session.`,
143
+ success: false,
144
+ };
145
+ }
146
+ return runProviderAddSetup(ui, createSetupFlow(type, options), options);
129
147
  }
130
148
 
131
- function createProviderSelectionInteraction(
149
+ async function askProviderSetupType(
150
+ ui: IUserInteraction,
132
151
  options: IProviderCommandModuleOptions,
133
- ): ICommandInteraction {
134
- return {
135
- prompt: {
136
- kind: 'choice' as const,
137
- title: 'Select provider',
138
- options: options.providerDefinitions.map((definition) => ({
139
- value: definition.type,
140
- label: formatProviderSetupChoiceLabel(definition),
141
- })),
142
- maxVisible: 6,
143
- },
144
- submit: (value: string) => {
145
- const flow = createSetupFlow(value, options);
146
- return {
147
- message: `Provider setup requested: ${value}`,
148
- success: true,
149
- interaction: createProviderSetupInteraction(flow, options),
150
- };
151
- },
152
- cancel: () => ({ message: 'Provider setup cancelled.', success: true }),
153
- };
152
+ ): Promise<ICommandResult> {
153
+ const typeOptions = options.providerDefinitions.map((definition) => ({
154
+ value: definition.type,
155
+ label: formatProviderSetupChoiceLabel(definition),
156
+ }));
157
+ const response = await ui.ask(
158
+ selectAction('provider-type', 'Select provider', typeOptions, { maxVisible: 6 }),
159
+ );
160
+ if (response.type !== 'answer' || response.values[0] === undefined) {
161
+ return { message: 'Provider setup cancelled.', success: true };
162
+ }
163
+ const type = response.values[0];
164
+ if (findProviderDefinition(options.providerDefinitions, type) === undefined) {
165
+ return {
166
+ message: `Usage: provider add <type>. Supported: ${formatSupportedProviderTypes(options.providerDefinitions)}`,
167
+ success: false,
168
+ };
169
+ }
170
+ return runProviderAddSetup(ui, createSetupFlow(type, options), options);
154
171
  }
@@ -6,11 +6,7 @@ import type {
6
6
  IProviderCommandSettingsAdapter,
7
7
  ISystemCommand as TSystemCommand,
8
8
  } from '@robota-sdk/agent-framework';
9
- import type {
10
- ICommand,
11
- ICommandInteractionHint,
12
- ICommandSource,
13
- } from '@robota-sdk/agent-interface-transport';
9
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
14
10
  export type { IProviderCommandModuleOptions, IProviderCommandSettingsAdapter };
15
11
 
16
12
  function buildProviderSubcommands(): ICommand[] {
@@ -56,22 +52,11 @@ function createProviderSystemCommand(options: IProviderCommandModuleOptions): TS
56
52
  modelInvocable: false,
57
53
  argumentHint: entry.argumentHint,
58
54
  subcommands: entry.subcommands,
59
- execute: async (_session, args) => executeProviderCommand(args, options),
55
+ lifecycle: 'inline',
56
+ execute: (context, args) => executeProviderCommand(context, args, options),
60
57
  };
61
58
  }
62
59
 
63
- const PROVIDER_INTERACTION_HINTS: Record<string, ICommandInteractionHint> = {
64
- provider: {
65
- type: 'pick',
66
- getItems: () =>
67
- buildProviderSubcommands().map((sub) => ({
68
- label: sub.name,
69
- value: sub.name,
70
- description: sub.description,
71
- })),
72
- },
73
- };
74
-
75
60
  export function createProviderCommandModule(
76
61
  options: IProviderCommandModuleOptions,
77
62
  ): TCommandModule {
@@ -79,6 +64,5 @@ export function createProviderCommandModule(
79
64
  name: 'agent-command-provider',
80
65
  commandSources: [new ProviderCommandSource()],
81
66
  systemCommands: [createProviderSystemCommand(options)],
82
- interactionHints: PROVIDER_INTERACTION_HINTS,
83
67
  };
84
68
  }