@robota-sdk/agent-command 3.0.0-beta.82 → 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.
- package/CHANGELOG.md +1071 -0
- package/README.md +71 -62
- package/dist/node/index.cjs +81 -72
- package/dist/node/index.d.cts +136 -52
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +136 -52
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +79 -70
- package/dist/node/index.js.map +1 -1
- package/package.json +16 -10
- package/src/agent/__tests__/agent-command.test.ts +62 -0
- package/src/agent/agent-command-parser.ts +2 -2
- package/src/agent/agent-command.ts +20 -0
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +2 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
- package/src/default/__tests__/default-command-modules.test.ts +54 -14
- package/src/default/__tests__/model-exposure.test.ts +18 -3
- package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
- package/src/default/default-command-modules.ts +15 -8
- package/src/devices/__tests__/devices-command-module.test.ts +99 -12
- package/src/devices/devices-command-module.ts +108 -24
- package/src/devices/devices-command-port.ts +62 -1
- package/src/devices/index.ts +3 -0
- package/src/doctor/doctor-node-deps.ts +6 -7
- package/src/editor/editor-command-module.ts +5 -0
- package/src/effort/effort-command.test.ts +65 -2
- package/src/effort/effort-command.ts +19 -5
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/git/__tests__/fake-git-port.ts +9 -2
- package/src/git/__tests__/git-command-module.test.ts +10 -2
- package/src/git/git-command-module.ts +3 -4
- package/src/git/git-commit.ts +6 -3
- package/src/git/index.ts +4 -1
- package/src/goal/goal-command.ts +3 -1
- package/src/handoff/__tests__/handoff-command-module.test.ts +2 -1
- package/src/handoff/handoff-command-module.ts +1 -1
- package/src/handoff/handoff-command.ts +2 -2
- package/src/help/__tests__/help-command-module.test.ts +3 -0
- package/src/help/__tests__/help-command.test.ts +3 -0
- package/src/index.ts +14 -0
- package/src/keybindings/keybindings-command-module.ts +17 -3
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +172 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +62 -4
- package/src/memory/__tests__/memory-command-module.test.ts +40 -2
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/memory-command-module.ts +4 -2
- package/src/memory/memory-command.ts +42 -2
- package/src/mode/__tests__/mode-command-module.test.ts +40 -0
- package/src/mode/mode-command.ts +1 -1
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +240 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +223 -0
- package/src/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +78 -0
- package/src/peers/peers-command-module.ts +1 -1
- package/src/peers/peers-command.ts +33 -4
- package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
- package/src/plugin/plugin-command.ts +47 -4
- package/src/provider/__tests__/org-policy.test.ts +4 -0
- package/src/provider/__tests__/provider-command-module.test.ts +276 -14
- package/src/provider/__tests__/scripted-interaction.ts +12 -2
- package/src/provider/provider-command-execution.ts +54 -8
- package/src/provider/provider-command-profile-lifecycle.ts +48 -2
- package/src/provider/provider-command-profile-operations.ts +14 -0
- package/src/provider/provider-command-profile.ts +7 -4
- package/src/provider/provider-command-setup.ts +14 -1
- package/src/rewind/__tests__/rewind-command-module.test.ts +32 -1
- package/src/rewind/rewind-command.ts +23 -2
- package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
- package/src/schedule/__tests__/loop-command.test.ts +238 -4
- package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
- package/src/schedule/loop-command.ts +98 -4
- package/src/schedule/schedule-command-module.ts +8 -4
- package/src/settings/settings-command-module.ts +2 -2
- package/src/shell/shell-command-module.ts +5 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +1 -1
- package/src/theme/theme-command-module.ts +12 -2
- package/src/user-local/__tests__/user-local-command.test.ts +2 -2
- package/src/git/__tests__/git-diff.test.ts +0 -125
- package/src/git/__tests__/git-process.test.ts +0 -221
- package/src/git/__tests__/git-status.test.ts +0 -117
- package/src/git/git-diff.ts +0 -129
- package/src/git/git-process.ts +0 -169
- package/src/git/git-status.ts +0 -134
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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(
|
|
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({
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
}
|
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
import { formatProviderChoiceLabel } from './provider-command-profile-operations.js';
|
|
9
9
|
|
|
10
10
|
import type { IUserInteraction } from '@robota-sdk/agent-core';
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
IProviderCommandModuleOptions,
|
|
13
|
+
IProviderProfileSettings,
|
|
14
|
+
} from '@robota-sdk/agent-framework';
|
|
12
15
|
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
13
16
|
|
|
14
17
|
const MAX_DUPLICATE_PROFILE_SUFFIX = 1000;
|
|
@@ -90,6 +93,47 @@ function completeProviderDuplicate(
|
|
|
90
93
|
};
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/**
|
|
97
|
+
* #3282 §4b: the direct, non-interactive delete itself — no replacement ask (the interactive
|
|
98
|
+
* per-profile menu's Delete, `buildProviderDelete` below, still offers one); refuses instead when the
|
|
99
|
+
* profile is the one in use. Never asks to CONFIRM either — its caller in `provider-command-execution`
|
|
100
|
+
* does that first, when a human can answer, before reaching this function; this function itself is
|
|
101
|
+
* called only once a delete is already decided (confirmed by a human, or `--confirmed` from the
|
|
102
|
+
* Settings screen's own ConfirmDialog).
|
|
103
|
+
*/
|
|
104
|
+
export function buildProviderProfileDelete(
|
|
105
|
+
providers: Record<string, IProviderProfileSettings> | undefined,
|
|
106
|
+
currentProvider: string | undefined,
|
|
107
|
+
profileName: string | undefined,
|
|
108
|
+
options: IProviderCommandModuleOptions,
|
|
109
|
+
): ICommandResult {
|
|
110
|
+
if (!profileName) {
|
|
111
|
+
return { message: 'Usage: /provider delete <profile>', success: false };
|
|
112
|
+
}
|
|
113
|
+
if (!providers?.[profileName]) {
|
|
114
|
+
return { message: `Provider profile "${profileName}" was not found.`, success: false };
|
|
115
|
+
}
|
|
116
|
+
if (Object.keys(providers).length <= 1) {
|
|
117
|
+
return { message: 'Cannot delete the only provider profile.', success: false };
|
|
118
|
+
}
|
|
119
|
+
if (currentProvider === profileName) {
|
|
120
|
+
return {
|
|
121
|
+
message: 'Cannot delete the profile in use. Switch to another profile first.',
|
|
122
|
+
success: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (options.settings.readTargetSettings().providers?.[profileName] === undefined) {
|
|
126
|
+
return {
|
|
127
|
+
message: `Provider profile "${profileName}" is not stored in the active write target; edit its source settings file or override it before deleting.`,
|
|
128
|
+
success: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
options.settings.writeTargetSettings(
|
|
132
|
+
deleteProviderProfile(options.settings.readTargetSettings(), profileName),
|
|
133
|
+
);
|
|
134
|
+
return { message: `Provider profile deleted: ${profileName}.`, success: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
93
137
|
export async function buildProviderDelete(
|
|
94
138
|
ui: IUserInteraction,
|
|
95
139
|
profileName: string,
|
|
@@ -109,8 +153,10 @@ export async function buildProviderDelete(
|
|
|
109
153
|
success: false,
|
|
110
154
|
};
|
|
111
155
|
}
|
|
156
|
+
// #3282 §2: the GUI renders this confirm on its shared ConfirmDialog (id 'provider-delete') with
|
|
157
|
+
// this exact title — kept in the quoted-name form the TUI's inline picker shows the same way.
|
|
112
158
|
const response = await ui.ask(
|
|
113
|
-
confirmAction('provider-delete', `Delete
|
|
159
|
+
confirmAction('provider-delete', `Delete profile "${profileName}"?`),
|
|
114
160
|
);
|
|
115
161
|
if (!isConfirmed(response)) {
|
|
116
162
|
return { message: 'Provider delete cancelled.', success: true };
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isApiKeyPlaintext,
|
|
5
5
|
setCurrentProvider,
|
|
6
6
|
upsertProviderProfile,
|
|
7
|
+
validateProviderProfile,
|
|
7
8
|
} from '@robota-sdk/agent-framework';
|
|
8
9
|
|
|
9
10
|
import { runProviderSetupAsk } from './provider-command-setup.js';
|
|
@@ -51,6 +52,19 @@ export function buildProviderSwitch(
|
|
|
51
52
|
return { message: `Already using provider "${profileName}".`, success: true };
|
|
52
53
|
}
|
|
53
54
|
const profile = providers[profileName];
|
|
55
|
+
// #3282: the hot-swap this returns as a `hostActions` entry builds this exact profile against
|
|
56
|
+
// these exact definitions (`resolveUserSettingsProviderSwitch` in agent-framework). Validating it
|
|
57
|
+
// HERE, before anything is written, means a switch that would fail changes nothing on disk — the
|
|
58
|
+
// previous behavior wrote `currentProvider` unconditionally and let a downstream hot-swap failure
|
|
59
|
+
// (e.g. "Unknown provider: anthropic. Currently supported: ", an empty list) stand uncorrected.
|
|
60
|
+
try {
|
|
61
|
+
validateProviderProfile(profileName, profile, { providerDefinitions: options.providerDefinitions });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return {
|
|
64
|
+
message: `Failed to switch to "${profileName}": ${error instanceof Error ? error.message : String(error)}`,
|
|
65
|
+
success: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
54
68
|
const target = options.settings.readTargetSettings();
|
|
55
69
|
const merged = options.settings.readMergedSettings();
|
|
56
70
|
const next =
|
|
@@ -23,7 +23,6 @@ const ACTION_EDIT = 'edit';
|
|
|
23
23
|
const ACTION_TEST = 'test';
|
|
24
24
|
const ACTION_DUPLICATE = 'duplicate';
|
|
25
25
|
const ACTION_DELETE = 'delete';
|
|
26
|
-
const ACTION_CANCEL = 'cancel';
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* Ask the user to pick a provider profile, then drive its action menu (CMD-004 inline ask). Replaces
|
|
@@ -58,6 +57,13 @@ async function askProviderProfileAction(
|
|
|
58
57
|
if (!settings.providers?.[profileName]) {
|
|
59
58
|
return { message: `Provider profile "${profileName}" was not found.`, success: false };
|
|
60
59
|
}
|
|
60
|
+
// #3282 §2: no separate 'cancel' option here — the picker's own persistent Cancel affordance
|
|
61
|
+
// (every ask renderer offers one, e.g. `PermissionPrompt`'s ghost Cancel button in agent-ui-web)
|
|
62
|
+
// is the only Cancel a person sees. A second one, indistinguishable from Switch/Edit/Test/Duplicate
|
|
63
|
+
// in a flat list, was the audited bug (#3282 issue text): "Delete looks the same as Switch... plus
|
|
64
|
+
// a second Cancel." Order matters too — Switch first as the primary action, then Edit/Test/
|
|
65
|
+
// Duplicate, with Delete last: a client (the GUI's provider-action menu) that wants Delete
|
|
66
|
+
// separated and styled destructive can rely on it always being the final entry.
|
|
61
67
|
const response = await ui.ask(
|
|
62
68
|
selectAction('provider-profile-action', `Provider profile: ${profileName}`, [
|
|
63
69
|
{ value: ACTION_SWITCH, label: 'Switch' },
|
|
@@ -65,7 +71,6 @@ async function askProviderProfileAction(
|
|
|
65
71
|
{ value: ACTION_TEST, label: 'Test' },
|
|
66
72
|
{ value: ACTION_DUPLICATE, label: 'Duplicate' },
|
|
67
73
|
{ value: ACTION_DELETE, label: 'Delete' },
|
|
68
|
-
{ value: ACTION_CANCEL, label: 'Cancel' },
|
|
69
74
|
]),
|
|
70
75
|
);
|
|
71
76
|
if (response.type !== 'answer' || response.values[0] === undefined) {
|
|
@@ -97,8 +102,6 @@ async function executeProviderProfileAction(
|
|
|
97
102
|
return buildProviderDuplicate(ui, profileName, options);
|
|
98
103
|
case ACTION_DELETE:
|
|
99
104
|
return buildProviderDelete(ui, profileName, options);
|
|
100
|
-
case ACTION_CANCEL:
|
|
101
|
-
return { message: 'Provider profile action cancelled.', success: true };
|
|
102
105
|
default:
|
|
103
106
|
return { message: `Unknown provider profile action "${action}".`, success: false };
|
|
104
107
|
}
|