@robota-sdk/agent-command 3.0.0-beta.83 → 3.0.0-beta.84

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 (61) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +71 -62
  3. package/dist/node/index.cjs +75 -72
  4. package/dist/node/index.d.cts +37 -45
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +37 -45
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +78 -75
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/src/agent/__tests__/agent-command.test.ts +62 -0
  12. package/src/agent/agent-command-parser.ts +2 -2
  13. package/src/agent/agent-command.ts +20 -0
  14. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
  15. package/src/default/__tests__/default-command-modules.test.ts +5 -4
  16. package/src/default/__tests__/model-exposure.test.ts +2 -0
  17. package/src/default/default-command-modules.ts +8 -0
  18. package/src/effort/effort-command.test.ts +65 -2
  19. package/src/effort/effort-command.ts +19 -5
  20. package/src/git/__tests__/fake-git-port.ts +9 -2
  21. package/src/git/git-command-module.ts +3 -4
  22. package/src/git/git-commit.ts +6 -3
  23. package/src/git/index.ts +4 -1
  24. package/src/goal/goal-command.ts +3 -1
  25. package/src/index.ts +6 -0
  26. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +131 -0
  27. package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
  28. package/src/mcp-activation/mcp-activation-command.ts +54 -1
  29. package/src/memory/__tests__/memory-command-module.test.ts +40 -2
  30. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  31. package/src/memory/memory-command-module.ts +4 -2
  32. package/src/memory/memory-command.ts +42 -2
  33. package/src/mode/__tests__/mode-command-module.test.ts +40 -0
  34. package/src/mode/mode-command.ts +1 -1
  35. package/src/model/__tests__/model-command-module.test.ts +80 -0
  36. package/src/model/__tests__/model-command.test.ts +240 -0
  37. package/src/model/index.ts +6 -0
  38. package/src/model/model-command-module.ts +56 -0
  39. package/src/model/model-command.ts +223 -0
  40. package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
  41. package/src/plugin/plugin-command.ts +47 -4
  42. package/src/provider/__tests__/org-policy.test.ts +4 -0
  43. package/src/provider/__tests__/provider-command-module.test.ts +276 -14
  44. package/src/provider/__tests__/scripted-interaction.ts +12 -2
  45. package/src/provider/provider-command-execution.ts +54 -8
  46. package/src/provider/provider-command-profile-lifecycle.ts +48 -2
  47. package/src/provider/provider-command-profile-operations.ts +14 -0
  48. package/src/provider/provider-command-profile.ts +7 -4
  49. package/src/provider/provider-command-setup.ts +14 -1
  50. package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
  51. package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
  52. package/src/schedule/__tests__/loop-command.test.ts +238 -4
  53. package/src/schedule/loop-command.ts +95 -3
  54. package/src/schedule/schedule-command-module.ts +8 -4
  55. package/src/settings/settings-command-module.ts +2 -2
  56. package/src/git/__tests__/git-diff.test.ts +0 -125
  57. package/src/git/__tests__/git-process.test.ts +0 -221
  58. package/src/git/__tests__/git-status.test.ts +0 -117
  59. package/src/git/git-diff.ts +0 -129
  60. package/src/git/git-process.ts +0 -169
  61. package/src/git/git-status.ts +0 -134
@@ -3,9 +3,13 @@ import {
3
3
  resolvePluginCommandAdapter,
4
4
  } from '@robota-sdk/agent-framework';
5
5
 
6
- import type { ICommandHostAdapterAccess } from '@robota-sdk/agent-framework';
6
+ import type { ICommandHostAdapterAccess, ICommandHostWorkspace } from '@robota-sdk/agent-framework';
7
7
  import type { ICommandPluginAdapter, ICommandResult } from '@robota-sdk/agent-interface-command';
8
8
 
9
+ /** What `/plugin` reaches: its adapter port, and — for install/uninstall — this connection's locality. */
10
+ export type TPluginCommandContext = ICommandHostAdapterAccess &
11
+ Partial<Pick<ICommandHostWorkspace, 'getCommandSurfaceLocality'>>;
12
+
9
13
  function getSubcommandParts(args: string): { subcommand: string; subArgs: string } {
10
14
  const parts = args
11
15
  .trim()
@@ -28,6 +32,20 @@ function getPluginAdapter(context: ICommandHostAdapterAccess): ICommandPluginAda
28
32
  return resolvePluginCommandAdapter(context);
29
33
  }
30
34
 
35
+ const REMOTE_INSTALL_REFUSAL =
36
+ 'Installing and uninstalling plugins runs code on this computer, so it only works from the ' +
37
+ 'desktop app or the page opened here — not from a remote device.';
38
+
39
+ /**
40
+ * #3282 §4 part b-2: the same rule the GUI's Settings screen applies before it ever sends the
41
+ * write — installing/uninstalling stays local-surface-only because it runs third-party code.
42
+ * `getCommandSurfaceLocality` absent (an older host, a test double, the in-process TUI) reads as
43
+ * `'local'`, matching the allow-by-default posture the rest of the command layer already uses.
44
+ */
45
+ function isRemoteSurface(context: TPluginCommandContext): boolean {
46
+ return (context.getCommandSurfaceLocality?.() ?? 'local') === 'remote';
47
+ }
48
+
31
49
  async function executePluginOperation(
32
50
  context: ICommandHostAdapterAccess,
33
51
  operation: (adapter: ICommandPluginAdapter) => Promise<string>,
@@ -123,9 +141,10 @@ function executePluginManager(): ICommandResult {
123
141
  }
124
142
 
125
143
  function executeInstallCommand(
126
- context: ICommandHostAdapterAccess,
144
+ context: TPluginCommandContext,
127
145
  pluginId: string,
128
146
  ): Promise<ICommandResult> {
147
+ if (isRemoteSurface(context)) return Promise.resolve(usage(REMOTE_INSTALL_REFUSAL));
129
148
  return executePluginIdOperation(
130
149
  context,
131
150
  pluginId,
@@ -138,9 +157,10 @@ function executeInstallCommand(
138
157
  }
139
158
 
140
159
  function executeUninstallCommand(
141
- context: ICommandHostAdapterAccess,
160
+ context: TPluginCommandContext,
142
161
  pluginId: string,
143
162
  ): Promise<ICommandResult> {
163
+ if (isRemoteSurface(context)) return Promise.resolve(usage(REMOTE_INSTALL_REFUSAL));
144
164
  return executePluginIdOperation(
145
165
  context,
146
166
  pluginId,
@@ -152,6 +172,27 @@ function executeUninstallCommand(
152
172
  );
153
173
  }
154
174
 
175
+ /** #3282 §4 part b-2: the Settings screen's Plugins section reads through this, like `/mcp status`. */
176
+ async function executeListInstalledCommand(
177
+ context: ICommandHostAdapterAccess,
178
+ ): Promise<ICommandResult> {
179
+ const adapter = getPluginAdapter(context);
180
+ if (adapter === undefined) {
181
+ return { success: true, message: 'Plugin management is not available.', data: { plugins: [] } };
182
+ }
183
+ try {
184
+ const plugins = await adapter.listInstalled();
185
+ const message =
186
+ plugins.length === 0
187
+ ? 'No plugins are installed.'
188
+ : `Installed plugins:\n${plugins.map((plugin) => ` ${plugin.name}${plugin.enabled ? '' : ' (disabled)'} — ${plugin.description}`).join('\n')}`;
189
+ return { success: true, message, data: { plugins } };
190
+ } catch (error) {
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ return { success: false, message: `Plugin error: ${message}` };
193
+ }
194
+ }
195
+
155
196
  function executeEnableCommand(
156
197
  context: ICommandHostAdapterAccess,
157
198
  pluginId: string,
@@ -183,7 +224,7 @@ function executeDisableCommand(
183
224
  }
184
225
 
185
226
  export async function executePluginCommand(
186
- context: ICommandHostAdapterAccess,
227
+ context: TPluginCommandContext,
187
228
  args: string,
188
229
  ): Promise<ICommandResult> {
189
230
  const { subcommand, subArgs } = getSubcommandParts(args);
@@ -191,6 +232,8 @@ export async function executePluginCommand(
191
232
  case '':
192
233
  case 'manage':
193
234
  return executePluginManager();
235
+ case 'list':
236
+ return executeListInstalledCommand(context);
194
237
  case 'install':
195
238
  return executeInstallCommand(context, subArgs);
196
239
  case 'uninstall':
@@ -97,6 +97,10 @@ describe('org policy enforcement in provider commands', () => {
97
97
  });
98
98
 
99
99
  it('allows /provider switch when target is in allowedProviders', async () => {
100
+ // #3282: the switch now validates the target profile against `providerDefinitions` before
101
+ // writing anything, the same way the hot-swap it triggers would build the provider — so the
102
+ // profile needs a resolvable credential, same as a real one would.
103
+ vi.stubEnv('OPENAI_API_KEY', 'sk-test');
100
104
  const { adapter } = createSettingsAdapter({
101
105
  currentProvider: 'anthropic',
102
106
  providers: {
@@ -169,6 +169,31 @@ describe('createProviderCommandModule', () => {
169
169
  expect(requests[1]?.title).toBe('Provider profile: anthropic');
170
170
  });
171
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
+
172
197
  it('switches provider immediately via /provider switch without confirmation dialog', async () => {
173
198
  const { adapter, readTarget } = createSettingsAdapter(
174
199
  {
@@ -323,7 +348,7 @@ describe('createProviderCommandModule', () => {
323
348
 
324
349
  // The confirm prompt must actually be issued — guards against a silent drop of the
325
350
  // confirmation step that would still "delete and pass".
326
- expect(requests[2]?.title).toBe('Delete provider profile anthropic?');
351
+ expect(requests[2]?.title).toBe('Delete profile "anthropic"?');
327
352
  expect(completed?.message).toBe('Provider profile deleted: anthropic.');
328
353
  expect(readTarget()).toEqual({
329
354
  currentProvider: 'openai',
@@ -360,7 +385,7 @@ describe('createProviderCommandModule', () => {
360
385
  const completed = await createExecutor(adapter).execute('provider', context, 'list');
361
386
 
362
387
  // The confirm precedes the replacement picker — assert both so neither step can be dropped silently.
363
- expect(requests[2]?.title).toBe('Delete provider profile anthropic?');
388
+ expect(requests[2]?.title).toBe('Delete profile "anthropic"?');
364
389
  expect(requests[3]?.title).toBe('Replacement provider for anthropic');
365
390
  expect(readTarget()).toEqual({
366
391
  currentProvider: 'openai',
@@ -399,14 +424,20 @@ describe('createProviderCommandModule', () => {
399
424
  expect(result?.message).toContain('not stored in the active write target');
400
425
  });
401
426
 
402
- it('owns provider setup flow and writes settings after generic prompt submissions', async () => {
427
+ it('owns provider setup flow, writes settings, and hot-swaps a session actually in setup mode (#3282 §3)', async () => {
403
428
  const { adapter, readTarget } = createSettingsAdapter({}, {});
404
429
 
405
- const { context, requests } = scriptedContext([
406
- { type: 'answer', values: [], text: '' },
407
- { type: 'answer', values: [], text: '' },
408
- { type: 'answer', values: [], text: '' },
409
- ]);
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
+ );
410
441
  const completed = await createExecutor(adapter).execute('provider', context, 'add openai');
411
442
 
412
443
  expect(requests[0]?.title).toBe('OpenAI-compatible base URL');
@@ -434,12 +465,26 @@ describe('createProviderCommandModule', () => {
434
465
  },
435
466
  },
436
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' });
437
486
  expect(completed?.hostActions).toEqual([
438
- {
439
- type: 'session-restart',
440
- reason: 'other',
441
- message: 'Provider setup restart',
442
- },
487
+ { type: 'session-restart', reason: 'other', message: 'Provider setup restart' },
443
488
  ]);
444
489
  });
445
490
 
@@ -493,8 +538,13 @@ describe('createProviderCommandModule', () => {
493
538
  { type: 'answer', values: [], text: '' },
494
539
  { type: 'answer', values: [], text: '' },
495
540
  ]);
496
- await createExecutor(adapter).execute('provider', context, 'add openai');
541
+ const completed = await createExecutor(adapter).execute('provider', context, 'add openai');
497
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
+ ]);
498
548
  expect(readTarget()).toMatchObject({
499
549
  currentProvider: 'openai-2',
500
550
  providers: {
@@ -542,4 +592,216 @@ describe('createProviderCommandModule', () => {
542
592
  expect(result?.message).toContain('manual configuration can continue');
543
593
  expect(probe).toHaveBeenCalled();
544
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
+ });
545
807
  });
@@ -8,7 +8,15 @@ import type { IActionRequest, IUserInteraction, TActionResponse } from '@robota-
8
8
  * the rendered action shape) and returns the next scripted answer; once the script is exhausted it
9
9
  * returns `{ type: 'cancelled' }`, mirroring a user dismissing the prompt.
10
10
  */
11
- export function scriptedContext(answers: readonly TActionResponse[]): {
11
+ export function scriptedContext(
12
+ answers: readonly TActionResponse[],
13
+ /**
14
+ * #3282 §3: whether THIS session is running with no provider configured yet — `/provider add`
15
+ * reads it (never settings) to decide hot-swap vs. restart. Defaults to `false`, an ordinary
16
+ * session, matching every test written before setup mode existed.
17
+ */
18
+ isSetupRequired = false,
19
+ ): {
12
20
  context: ReturnType<typeof createTestCommandHost>;
13
21
  requests: IActionRequest[];
14
22
  } {
@@ -25,6 +33,8 @@ export function scriptedContext(answers: readonly TActionResponse[]): {
25
33
  // ARCH-029: the double answers "no capability of that kind" for everything else, so this fixture
26
34
  // states exactly the one capability it scripts. The double cast it replaced —
27
35
  // `as Partial<…> as ICommandHostContext` — asserted conformance twice over one member.
28
- const context = createTestCommandHost({ overrides: { getUserInteraction: () => ui } });
36
+ const context = createTestCommandHost({
37
+ overrides: { getUserInteraction: () => ui, isSetupRequired: () => isSetupRequired },
38
+ });
29
39
  return { context, requests };
30
40
  }
@@ -1,17 +1,24 @@
1
1
  import {
2
+ confirmAction,
2
3
  findProviderDefinition,
3
4
  formatSupportedProviderTypes,
5
+ isConfirmed,
4
6
  selectAction,
5
7
  } from '@robota-sdk/agent-core';
6
8
  import { testProviderProfileCommand } from '@robota-sdk/agent-framework';
7
9
 
8
- import { buildProviderSwitch } from './provider-command-profile-operations.js';
10
+ import {
11
+ buildProviderDuplicate,
12
+ buildProviderProfileDelete,
13
+ } from './provider-command-profile-lifecycle.js';
14
+ import { buildProviderEdit, buildProviderSwitch } from './provider-command-profile-operations.js';
9
15
  import { askProviderProfileSelection } from './provider-command-profile.js';
10
16
  import { createSetupFlow, runProviderAddSetup } from './provider-command-setup.js';
11
17
  import { formatProviderSetupChoiceLabel } from './provider-setup-flow.js';
12
18
 
13
19
  import type { IUserInteraction } from '@robota-sdk/agent-core';
14
20
  import type {
21
+ ICommandHostSetupState,
15
22
  ICommandHostUserInteraction,
16
23
  IProviderCommandModuleOptions,
17
24
  IProviderProfileSettings,
@@ -19,17 +26,21 @@ import type {
19
26
  import type { ICommandResult } from '@robota-sdk/agent-interface-command';
20
27
 
21
28
  export async function executeProviderCommand(
22
- context: ICommandHostUserInteraction,
29
+ context: ICommandHostUserInteraction & ICommandHostSetupState,
23
30
  args: string,
24
31
  options: IProviderCommandModuleOptions,
25
32
  ): Promise<ICommandResult> {
26
33
  const ui = context.getUserInteraction();
34
+ // #3282 §3: the session's own live state, never inferred from settings — an ordinary env-default
35
+ // session also has no persisted `currentProvider`, and is not in setup mode either.
36
+ const isSetupRequired = context.isSetupRequired?.() === true;
27
37
  const settings = options.settings.readMergedSettings();
28
38
  const trimmedArgs = args.trim();
29
39
  if (trimmedArgs.length === 0) {
30
40
  return buildProviderProfilePicker(ui, settings.currentProvider, settings.providers, options);
31
41
  }
32
- const [subcommand = 'current', profileArg] = trimmedArgs.split(/\s+/);
42
+ const argTokens = trimmedArgs.split(/\s+/);
43
+ const [subcommand = 'current', profileArg] = argTokens;
33
44
 
34
45
  if (subcommand === 'list') {
35
46
  return buildProviderProfilePicker(ui, settings.currentProvider, settings.providers, options);
@@ -51,12 +62,45 @@ export async function executeProviderCommand(
51
62
  options,
52
63
  );
53
64
  }
65
+ // #3282 §4b: direct, non-interactive forms of what the profile-action menu otherwise reaches
66
+ // through two asks (`askProviderProfileSelection` then `askProviderProfileAction`) — for a caller
67
+ // (the Settings screen, or a typed command) that already names the profile and does not want the
68
+ // "which profile" ask repeated. `edit`/`duplicate` still ask their own follow-up questions (the
69
+ // new name, the changed fields) exactly as the menu's Edit/Duplicate do.
70
+ if (subcommand === 'edit') {
71
+ if (!ui) return { message: 'Provider edit requires an interactive session.', success: false };
72
+ if (!profileArg) return { message: 'Usage: provider edit <profile>', success: false };
73
+ return buildProviderEdit(ui, profileArg, options);
74
+ }
75
+ if (subcommand === 'duplicate') {
76
+ if (!ui) return { message: 'Provider duplicate requires an interactive session.', success: false };
77
+ if (!profileArg) return { message: 'Usage: provider duplicate <profile>', success: false };
78
+ return buildProviderDuplicate(ui, profileArg, options);
79
+ }
80
+ if (subcommand === 'delete') {
81
+ // #3282 §4b: `--confirmed` is set ONLY by the Settings screen's own write (`settings-reporter.ts`)
82
+ // — its "Delete…" button already confirmed through its own ConfirmDialog before sending this, and
83
+ // a modal write has nowhere to ask a follow-up question. A plainly-typed `delete <profile>` never
84
+ // carries it, so it confirms here first when a human can answer (matching `/clear`'s "confirm only
85
+ // when interactive; with no human the explicit form proceeds") — deleting a profile also deletes
86
+ // its stored key, unlike `switch`, which this direct-form family otherwise mirrors.
87
+ if (!profileArg) return { message: 'Usage: provider delete <profile>', success: false };
88
+ const preConfirmed = argTokens.includes('--confirmed');
89
+ if (!preConfirmed && ui) {
90
+ const response = await ui.ask(confirmAction('provider-delete', `Delete profile "${profileArg}"?`));
91
+ if (!isConfirmed(response)) {
92
+ return { message: 'Provider delete cancelled.', success: true };
93
+ }
94
+ }
95
+ return buildProviderProfileDelete(settings.providers, settings.currentProvider, profileArg, options);
96
+ }
54
97
  if (subcommand === 'add') {
55
- return buildProviderSetup(ui, profileArg, options);
98
+ return buildProviderSetup(ui, profileArg, options, isSetupRequired);
56
99
  }
57
100
 
58
101
  return {
59
- message: 'Usage: provider [current|list|switch <profile>|add <type>|test [profile]]',
102
+ message:
103
+ 'Usage: provider [current|list|switch <profile>|edit <profile>|duplicate <profile>|delete <profile>|add <type>|test [profile]]',
60
104
  success: false,
61
105
  };
62
106
  }
@@ -121,6 +165,7 @@ function buildProviderSetup(
121
165
  ui: IUserInteraction | undefined,
122
166
  type: string | undefined,
123
167
  options: IProviderCommandModuleOptions,
168
+ isSetupRequired: boolean,
124
169
  ): Promise<ICommandResult> | ICommandResult {
125
170
  if (type === undefined || type.length === 0) {
126
171
  if (!ui) {
@@ -129,7 +174,7 @@ function buildProviderSetup(
129
174
  success: false,
130
175
  };
131
176
  }
132
- return askProviderSetupType(ui, options);
177
+ return askProviderSetupType(ui, options, isSetupRequired);
133
178
  }
134
179
  if (findProviderDefinition(options.providerDefinitions, type) === undefined) {
135
180
  return {
@@ -143,12 +188,13 @@ function buildProviderSetup(
143
188
  success: false,
144
189
  };
145
190
  }
146
- return runProviderAddSetup(ui, createSetupFlow(type, options), options);
191
+ return runProviderAddSetup(ui, createSetupFlow(type, options), options, isSetupRequired);
147
192
  }
148
193
 
149
194
  async function askProviderSetupType(
150
195
  ui: IUserInteraction,
151
196
  options: IProviderCommandModuleOptions,
197
+ isSetupRequired: boolean,
152
198
  ): Promise<ICommandResult> {
153
199
  const typeOptions = options.providerDefinitions.map((definition) => ({
154
200
  value: definition.type,
@@ -167,5 +213,5 @@ async function askProviderSetupType(
167
213
  success: false,
168
214
  };
169
215
  }
170
- return runProviderAddSetup(ui, createSetupFlow(type, options), options);
216
+ return runProviderAddSetup(ui, createSetupFlow(type, options), options, isSetupRequired);
171
217
  }