@robota-sdk/agent-command 3.0.0-beta.88 → 3.0.0-beta.92
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 +79 -0
- package/dist/node/index.cjs +6 -6
- package/dist/node/index.d.cts +7 -1
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +7 -1
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +6 -6
- package/dist/node/index.js.map +1 -1
- package/package.json +7 -7
- package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +8 -0
- package/src/model/__tests__/model-command.test.ts +25 -0
- package/src/model/model-command.ts +1 -0
- package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
- package/src/provider/__tests__/provider-command-module.test.ts +77 -0
- package/src/provider/provider-command-module.ts +9 -2
- package/src/provider/provider-command-profile-operations.ts +6 -1
- package/src/provider/provider-command-setup.ts +3 -1
- package/src/provider/provider-setup-flow.ts +4 -1
- package/src/provider/provider-startup.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-command",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.92",
|
|
4
4
|
"description": "Slash-command modules for Robota SDK sessions (/help, /permissions, /mcp, /rewind, …)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/node/index.js",
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"CHANGELOG.md"
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@robota-sdk/agent-core": "3.0.0-beta.
|
|
44
|
-
"@robota-sdk/agent-framework": "3.0.0-beta.
|
|
45
|
-
"@robota-sdk/agent-interface-command": "3.0.0-beta.
|
|
46
|
-
"@robota-sdk/agent-interface-execution": "3.0.0-beta.
|
|
47
|
-
"@robota-sdk/agent-interface-session": "3.0.0-beta.
|
|
48
|
-
"@robota-sdk/agent-preset": "3.0.0-beta.
|
|
43
|
+
"@robota-sdk/agent-core": "3.0.0-beta.92",
|
|
44
|
+
"@robota-sdk/agent-framework": "3.0.0-beta.92",
|
|
45
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.92",
|
|
46
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.92",
|
|
47
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.92",
|
|
48
|
+
"@robota-sdk/agent-preset": "3.0.0-beta.92"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^22.19.21",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3459 — the default command modules hand `/provider` and `/model` the host's environment snapshot.
|
|
3
|
+
*
|
|
4
|
+
* A host that withholds its own credentials from `process.env` passes the snapshot it took first as
|
|
5
|
+
* `providerEnvironment`. This drives both commands through the factory, with the key gone from the
|
|
6
|
+
* live env, so it fails exactly when the factory stops forwarding the snapshot.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
14
|
+
import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
|
|
15
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
16
|
+
|
|
17
|
+
import { createDefaultCommandModules } from '../default-command-modules.js';
|
|
18
|
+
|
|
19
|
+
import type { IProviderDefinition } from '@robota-sdk/agent-core';
|
|
20
|
+
import type { IProviderCommandSettingsAdapter, TProviderSettingsDocument } from '@robota-sdk/agent-framework';
|
|
21
|
+
|
|
22
|
+
const KEY = 'OPENAI_API_KEY';
|
|
23
|
+
const providerDefinitions: readonly IProviderDefinition[] = [
|
|
24
|
+
{
|
|
25
|
+
type: 'anthropic',
|
|
26
|
+
displayName: 'anthropic',
|
|
27
|
+
defaults: { model: 'm' },
|
|
28
|
+
modelCatalog: { status: 'live', entries: [{ id: 'm', displayName: 'm' }] },
|
|
29
|
+
createProvider: () => {
|
|
30
|
+
throw new Error('not used');
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
type: 'openai',
|
|
35
|
+
displayName: 'openai',
|
|
36
|
+
defaults: { apiKey: `$ENV:${KEY}` },
|
|
37
|
+
modelCatalog: { status: 'unavailable' },
|
|
38
|
+
requiresApiKey: true,
|
|
39
|
+
createProvider: () => {
|
|
40
|
+
throw new Error('not used');
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const SETTINGS: TProviderSettingsDocument = {
|
|
46
|
+
currentProvider: 'anthropic',
|
|
47
|
+
providers: { anthropic: { type: 'anthropic', model: 'm' }, openai: { type: 'openai', model: 'gpt-x' } },
|
|
48
|
+
};
|
|
49
|
+
const adapter: IProviderCommandSettingsAdapter = {
|
|
50
|
+
readMergedSettings: () => SETTINGS,
|
|
51
|
+
readTargetSettings: () => ({}),
|
|
52
|
+
writeTargetSettings: () => undefined,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'agent-test-provider-env-'));
|
|
56
|
+
afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
|
|
57
|
+
|
|
58
|
+
function executorThroughFactory(moduleName: string): SystemCommandExecutor {
|
|
59
|
+
const { modules } = createDefaultCommandModules({
|
|
60
|
+
cwd: '/work',
|
|
61
|
+
userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
|
|
62
|
+
providerDefinitions,
|
|
63
|
+
providerSettingsAdapter: adapter,
|
|
64
|
+
providerEnvironment: { [KEY]: 'snapshot-key' },
|
|
65
|
+
});
|
|
66
|
+
const found = modules.find((module) => module.name === moduleName);
|
|
67
|
+
if (found === undefined) throw new Error(`${moduleName} was not built`);
|
|
68
|
+
return new SystemCommandExecutor([...(found.systemCommands ?? [])]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function withoutLiveKey<T>(run: () => Promise<T>): Promise<T> {
|
|
72
|
+
const live = process.env[KEY];
|
|
73
|
+
delete process.env[KEY];
|
|
74
|
+
try {
|
|
75
|
+
return await run();
|
|
76
|
+
} finally {
|
|
77
|
+
if (live !== undefined) process.env[KEY] = live;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('#3459: the default command modules forward the provider environment', () => {
|
|
82
|
+
it('/provider switch checks the key against the snapshot', async () => {
|
|
83
|
+
const result = await withoutLiveKey(() =>
|
|
84
|
+
executorThroughFactory('agent-command-provider').execute('provider', createTestCommandHost(), 'switch openai'),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(result?.success).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('a cross-profile /model checks the key against the snapshot', async () => {
|
|
91
|
+
const host = createTestCommandHost({ session: { getModelId: () => 'm' } });
|
|
92
|
+
const result = await withoutLiveKey(() =>
|
|
93
|
+
executorThroughFactory('agent-command-model').execute('model', host, 'gpt-x'),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(result?.success).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -74,6 +74,11 @@ export interface IDefaultCommandModulesOptions {
|
|
|
74
74
|
* red when the producer dropped it.
|
|
75
75
|
*/
|
|
76
76
|
orgPolicy?: IOrgPolicy;
|
|
77
|
+
/**
|
|
78
|
+
* Where `/provider` and `/model` check a `$ENV:` or default credential variable: the host's startup
|
|
79
|
+
* snapshot when it withholds its own credentials from `process.env`. Absent, `process.env` is read.
|
|
80
|
+
*/
|
|
81
|
+
providerEnvironment?: Readonly<Record<string, string | undefined>>;
|
|
77
82
|
/** Optional TUI-owned file capability; absent, `/keybindings` says it belongs to an interactive terminal. */
|
|
78
83
|
keybindingsFilePort?: IKeybindingsFilePort;
|
|
79
84
|
/**
|
|
@@ -152,6 +157,7 @@ export function createDefaultCommandModules({
|
|
|
152
157
|
loopOptions,
|
|
153
158
|
enabledCommandModules,
|
|
154
159
|
disabledCommandModules,
|
|
160
|
+
providerEnvironment,
|
|
155
161
|
}: IDefaultCommandModulesOptions): IDefaultCommandModulesResult {
|
|
156
162
|
const modules: readonly ICommandModule[] = [
|
|
157
163
|
createSkillsCommandModule({ contributionSources: contributionSources ?? [], skillRoots }),
|
|
@@ -204,6 +210,7 @@ export function createDefaultCommandModules({
|
|
|
204
210
|
providerDefinitions,
|
|
205
211
|
settings: providerSettingsAdapter,
|
|
206
212
|
...(orgPolicy === undefined ? {} : { orgPolicy }),
|
|
213
|
+
...(providerEnvironment === undefined ? {} : { env: providerEnvironment }),
|
|
207
214
|
}),
|
|
208
215
|
// #3282 §2: shares the provider command's settings adapter and definitions — one settings write
|
|
209
216
|
// owner, never a second copy that could drift from what `/provider switch` reads and writes.
|
|
@@ -211,6 +218,7 @@ export function createDefaultCommandModules({
|
|
|
211
218
|
providerDefinitions,
|
|
212
219
|
settings: providerSettingsAdapter,
|
|
213
220
|
...(orgPolicy === undefined ? {} : { orgPolicy }),
|
|
221
|
+
...(providerEnvironment === undefined ? {} : { env: providerEnvironment }),
|
|
214
222
|
}),
|
|
215
223
|
];
|
|
216
224
|
const builtModuleNames = modules.map((module) => module.name);
|
|
@@ -237,4 +237,29 @@ describe('/model', () => {
|
|
|
237
237
|
expect(result).toEqual({ message: 'Model selection cancelled.', success: true });
|
|
238
238
|
expect(writeTargetSettings).not.toHaveBeenCalled();
|
|
239
239
|
});
|
|
240
|
+
|
|
241
|
+
// #3459: the host withholds its own credentials from `process.env`; the profile's `$ENV:` key is
|
|
242
|
+
// checked against the snapshot it hands the command instead.
|
|
243
|
+
it('switches to a profile whose key is only in the host snapshot', async () => {
|
|
244
|
+
const writeTargetSettings = vi.fn();
|
|
245
|
+
const host = createTestCommandHost({ session: { getModelId: () => 'claude-sonnet-4-6' } });
|
|
246
|
+
const keyed = DEFINITIONS.map((definition) =>
|
|
247
|
+
definition.type === 'openai' ? { ...definition, requiresApiKey: true } : definition,
|
|
248
|
+
);
|
|
249
|
+
const options = {
|
|
250
|
+
...buildOptions({ writeTargetSettings }),
|
|
251
|
+
providerDefinitions: keyed,
|
|
252
|
+
env: { OPENAI_API_KEY: 'snapshot-key' },
|
|
253
|
+
};
|
|
254
|
+
const live = process.env['OPENAI_API_KEY'];
|
|
255
|
+
delete process.env['OPENAI_API_KEY'];
|
|
256
|
+
try {
|
|
257
|
+
const result = await executeModelCommand(host, 'gpt-5.1', options);
|
|
258
|
+
|
|
259
|
+
expect(result.success).toBe(true);
|
|
260
|
+
expect(result.hostActions).toEqual([{ type: 'provider-hot-swap', profileName: 'my-openai' }]);
|
|
261
|
+
} finally {
|
|
262
|
+
if (live !== undefined) process.env['OPENAI_API_KEY'] = live;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
240
265
|
});
|
|
@@ -199,6 +199,7 @@ function switchModelAcrossProfiles(
|
|
|
199
199
|
try {
|
|
200
200
|
validateProviderProfile(selection.profileName, updatedProfile, {
|
|
201
201
|
providerDefinitions: options.providerDefinitions,
|
|
202
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
202
203
|
});
|
|
203
204
|
} catch (error) {
|
|
204
205
|
return {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { IProviderDefinition } from '@robota-sdk/agent-core';
|
|
3
|
+
import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
|
|
4
|
+
import type { TProviderSettingsDocument } from '@robota-sdk/agent-framework';
|
|
5
|
+
import { createProviderCommandModule } from '../provider-command-module.js';
|
|
6
|
+
import { scriptedContext } from './scripted-interaction.js';
|
|
7
|
+
|
|
8
|
+
const localDefinition: IProviderDefinition = {
|
|
9
|
+
type: 'local',
|
|
10
|
+
defaults: { model: 'installed-model', baseURL: 'http://localhost:1234/v1' },
|
|
11
|
+
setupSteps: [
|
|
12
|
+
{ key: 'baseURL', title: 'Server URL', defaultValue: 'http://localhost:1234/v1' },
|
|
13
|
+
{ key: 'model', title: 'Installed model', defaultValue: 'installed-model' },
|
|
14
|
+
{ key: 'apiKey', title: 'Server API key (optional)', masked: true, editOnly: true },
|
|
15
|
+
],
|
|
16
|
+
requiresApiKey: false,
|
|
17
|
+
createProvider: () => {
|
|
18
|
+
throw new Error('not used');
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function commands(initial: TProviderSettingsDocument = {}) {
|
|
23
|
+
let settings = initial;
|
|
24
|
+
const module = createProviderCommandModule({
|
|
25
|
+
providerDefinitions: [localDefinition],
|
|
26
|
+
env: { LOCAL_TOKEN: 'synthetic-selected-token' },
|
|
27
|
+
settings: {
|
|
28
|
+
readMergedSettings: () => settings,
|
|
29
|
+
readTargetSettings: () => settings,
|
|
30
|
+
writeTargetSettings: (next) => {
|
|
31
|
+
settings = next;
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
executor: new SystemCommandExecutor([...(module.systemCommands ?? [])]),
|
|
37
|
+
read: () => settings,
|
|
38
|
+
module,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('optional provider credentials', () => {
|
|
43
|
+
it('adds a keyless profile after only endpoint and model answers', async () => {
|
|
44
|
+
const { executor, read } = commands();
|
|
45
|
+
const { context, requests } = scriptedContext([
|
|
46
|
+
{ type: 'answer', values: [], text: '' },
|
|
47
|
+
{ type: 'answer', values: [], text: '' },
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const result = await executor.execute('provider', context, 'add local');
|
|
51
|
+
|
|
52
|
+
expect(result?.success).toBe(true);
|
|
53
|
+
expect(requests.map((request) => request.id)).toEqual([
|
|
54
|
+
'provider-setup-baseURL',
|
|
55
|
+
'provider-setup-model',
|
|
56
|
+
]);
|
|
57
|
+
expect(read().providers?.local).toEqual({
|
|
58
|
+
type: 'local',
|
|
59
|
+
model: 'installed-model',
|
|
60
|
+
baseURL: 'http://localhost:1234/v1',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it.each([undefined, '$ENV:LOCAL_TOKEN', 'synthetic-existing-token'])(
|
|
65
|
+
'allows blank optional key editing and preserves the previous credential %s',
|
|
66
|
+
async (apiKey) => {
|
|
67
|
+
const profile = {
|
|
68
|
+
type: 'local',
|
|
69
|
+
model: 'installed-model',
|
|
70
|
+
baseURL: 'http://localhost:1234/v1',
|
|
71
|
+
...(apiKey === undefined ? {} : { apiKey }),
|
|
72
|
+
};
|
|
73
|
+
const { executor, read } = commands({ providers: { local: profile } });
|
|
74
|
+
const { context, requests } = scriptedContext([
|
|
75
|
+
{ type: 'answer', values: [], text: '' },
|
|
76
|
+
{ type: 'answer', values: [], text: 'another-installed-model' },
|
|
77
|
+
{ type: 'answer', values: [], text: '' },
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const result = await executor.execute('provider', context, 'edit local');
|
|
81
|
+
|
|
82
|
+
expect(result?.success).toBe(true);
|
|
83
|
+
expect(requests[2]).toMatchObject({
|
|
84
|
+
id: 'provider-setup-apiKey',
|
|
85
|
+
masked: true,
|
|
86
|
+
allowEmpty: true,
|
|
87
|
+
});
|
|
88
|
+
expect(JSON.stringify(requests)).not.toContain('synthetic-existing-token');
|
|
89
|
+
expect(read().providers?.local).toEqual({ ...profile, model: 'another-installed-model' });
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
it('keeps credential management user-only and advertises the edit recovery action', () => {
|
|
94
|
+
const { module } = commands();
|
|
95
|
+
const entry = module.commandSources?.[0]?.getCommands()[0];
|
|
96
|
+
expect(entry).toMatchObject({ modelInvocable: false });
|
|
97
|
+
expect(module.systemCommands?.[0]).toMatchObject({
|
|
98
|
+
modelInvocable: false,
|
|
99
|
+
userInvocable: true,
|
|
100
|
+
});
|
|
101
|
+
expect(entry?.argumentHint).toContain('edit <profile>');
|
|
102
|
+
expect(entry?.description).toContain('credentials');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each(['$ENV:MISSING_SERVER_TOKEN', '$ENV:'])(
|
|
106
|
+
'leaves the working profile untouched when an edit supplies %s',
|
|
107
|
+
async (apiKey) => {
|
|
108
|
+
const initial = {
|
|
109
|
+
providers: {
|
|
110
|
+
local: {
|
|
111
|
+
type: 'local',
|
|
112
|
+
model: 'installed-model',
|
|
113
|
+
baseURL: 'http://localhost:1234/v1',
|
|
114
|
+
apiKey: 'synthetic-working-token',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
const { executor, read } = commands(initial);
|
|
119
|
+
const { context } = scriptedContext([
|
|
120
|
+
{ type: 'answer', values: [], text: '' },
|
|
121
|
+
{ type: 'answer', values: [], text: '' },
|
|
122
|
+
{ type: 'answer', values: [], text: apiKey },
|
|
123
|
+
]);
|
|
124
|
+
await expect(executor.execute('provider', context, 'edit local')).rejects.toThrow(
|
|
125
|
+
'missing apiKey',
|
|
126
|
+
);
|
|
127
|
+
expect(read()).toEqual(initial);
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
});
|
|
@@ -804,4 +804,81 @@ describe('createProviderCommandModule', () => {
|
|
|
804
804
|
expect(result?.message).toBe('Provider profile "ghost" was not found.');
|
|
805
805
|
});
|
|
806
806
|
});
|
|
807
|
+
|
|
808
|
+
// #3459: a host that withholds its own credentials from `process.env` checks `$ENV:` credentials
|
|
809
|
+
// against the snapshot it took first, which it hands the command as `env`.
|
|
810
|
+
describe('credentials the host withheld from process.env', () => {
|
|
811
|
+
const settings: TProviderSettingsDocument = {
|
|
812
|
+
currentProvider: 'openai',
|
|
813
|
+
providers: {
|
|
814
|
+
openai: { type: 'openai', model: 'supergemma4-26b-uncensored-v2' },
|
|
815
|
+
anthropic: { type: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
const snapshot = { ANTHROPIC_API_KEY: 'snapshot-key' };
|
|
819
|
+
|
|
820
|
+
function withoutLiveKey<T>(run: () => Promise<T>): Promise<T> {
|
|
821
|
+
const live = process.env['ANTHROPIC_API_KEY'];
|
|
822
|
+
delete process.env['ANTHROPIC_API_KEY'];
|
|
823
|
+
return run().finally(() => {
|
|
824
|
+
if (live !== undefined) process.env['ANTHROPIC_API_KEY'] = live;
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function executorWithSnapshot(adapter: IProviderCommandSettingsAdapter): SystemCommandExecutor {
|
|
829
|
+
const module = createProviderCommandModule({ providerDefinitions, settings: adapter, env: snapshot });
|
|
830
|
+
return new SystemCommandExecutor([...(module.systemCommands ?? [])]);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
it('switches to a profile whose key is only in the snapshot', async () => {
|
|
834
|
+
const { adapter, readTarget } = createSettingsAdapter(settings);
|
|
835
|
+
const result = await withoutLiveKey(() =>
|
|
836
|
+
executorWithSnapshot(adapter).execute('provider', headlessContext, 'switch anthropic'),
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
expect(result?.success).toBe(true);
|
|
840
|
+
expect(readTarget().currentProvider).toBe('anthropic');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('adds a profile whose default key is only in the snapshot', async () => {
|
|
844
|
+
const { adapter, readTarget } = createSettingsAdapter(settings);
|
|
845
|
+
const { context } = scriptedContext([
|
|
846
|
+
{ type: 'answer', values: [], text: '' },
|
|
847
|
+
{ type: 'answer', values: [], text: '' },
|
|
848
|
+
]);
|
|
849
|
+
const module = createProviderCommandModule({ providerDefinitions, settings: adapter, env: snapshot });
|
|
850
|
+
const result = await withoutLiveKey(() =>
|
|
851
|
+
new SystemCommandExecutor([...(module.systemCommands ?? [])]).execute('provider', context, 'add anthropic'),
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
expect(result?.success).not.toBe(false);
|
|
855
|
+
expect(readTarget().providers?.['anthropic-2']).toMatchObject({ type: 'anthropic' });
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('edits a profile whose default key is only in the snapshot', async () => {
|
|
859
|
+
const { adapter, readTarget } = createSettingsAdapter(settings);
|
|
860
|
+
const { context } = scriptedContext([
|
|
861
|
+
{ type: 'answer', values: [], text: '' },
|
|
862
|
+
{ type: 'answer', values: [], text: 'claude-opus-4-5' },
|
|
863
|
+
]);
|
|
864
|
+
const module = createProviderCommandModule({ providerDefinitions, settings: adapter, env: snapshot });
|
|
865
|
+
const result = await withoutLiveKey(() =>
|
|
866
|
+
new SystemCommandExecutor([...(module.systemCommands ?? [])]).execute('provider', context, 'edit anthropic'),
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
expect(result?.message).toBe('Provider anthropic updated.');
|
|
870
|
+
expect(readTarget().providers?.['anthropic']).toMatchObject({ model: 'claude-opus-4-5' });
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('tests a profile whose key is only in the snapshot', async () => {
|
|
874
|
+
const { adapter } = createSettingsAdapter(settings);
|
|
875
|
+
const result = await withoutLiveKey(() =>
|
|
876
|
+
executorWithSnapshot(adapter).execute('provider', headlessContext, 'test anthropic'),
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
expect(result?.message).toBe(
|
|
880
|
+
'Provider "anthropic" test passed: Profile fields are valid; no endpoint probe configured.',
|
|
881
|
+
);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
807
884
|
});
|
|
@@ -15,6 +15,11 @@ function buildProviderSubcommands(): ICommand[] {
|
|
|
15
15
|
{ name: 'list', description: 'List provider profiles', source: 'provider' },
|
|
16
16
|
{ name: 'switch', description: 'Hot-swap to another provider profile', source: 'provider' },
|
|
17
17
|
{ name: 'add', description: 'Configure a provider profile', source: 'provider' },
|
|
18
|
+
{
|
|
19
|
+
name: 'edit',
|
|
20
|
+
description: 'Edit a profile, including optional server credentials',
|
|
21
|
+
source: 'provider',
|
|
22
|
+
},
|
|
18
23
|
{ name: 'test', description: 'Test provider profile', source: 'provider' },
|
|
19
24
|
];
|
|
20
25
|
}
|
|
@@ -23,11 +28,13 @@ export function createProviderCommandEntry(): ICommand {
|
|
|
23
28
|
return {
|
|
24
29
|
name: 'provider',
|
|
25
30
|
displayName: 'Provider Setup',
|
|
26
|
-
description:
|
|
31
|
+
description:
|
|
32
|
+
'Show, configure or switch provider profiles. Suggest editing a profile when its URL, model or credentials need correction; returns profile details or the result of the change.',
|
|
27
33
|
source: 'provider',
|
|
28
34
|
// User-only: provider profiles hold account credentials; a credential action.
|
|
29
35
|
modelInvocable: false,
|
|
30
|
-
argumentHint:
|
|
36
|
+
argumentHint:
|
|
37
|
+
'current | list | switch <profile> | add [type] | edit <profile> | test [profile]',
|
|
31
38
|
subcommands: buildProviderSubcommands(),
|
|
32
39
|
example: '/provider switch production',
|
|
33
40
|
};
|
|
@@ -58,7 +58,10 @@ export function buildProviderSwitch(
|
|
|
58
58
|
// previous behavior wrote `currentProvider` unconditionally and let a downstream hot-swap failure
|
|
59
59
|
// (e.g. "Unknown provider: anthropic. Currently supported: ", an empty list) stand uncorrected.
|
|
60
60
|
try {
|
|
61
|
-
validateProviderProfile(profileName, profile, {
|
|
61
|
+
validateProviderProfile(profileName, profile, {
|
|
62
|
+
providerDefinitions: options.providerDefinitions,
|
|
63
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
64
|
+
});
|
|
62
65
|
} catch (error) {
|
|
63
66
|
return {
|
|
64
67
|
message: `Failed to switch to "${profileName}": ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -96,6 +99,7 @@ export async function buildProviderEdit(
|
|
|
96
99
|
let flow;
|
|
97
100
|
try {
|
|
98
101
|
flow = createProviderSetupFlow(profile.type, options.providerDefinitions, {
|
|
102
|
+
includeEditOnlySteps: true,
|
|
99
103
|
profileName,
|
|
100
104
|
setCurrent: false,
|
|
101
105
|
initialValues: getProviderProfileSetupValues(profile),
|
|
@@ -146,6 +150,7 @@ function completeProviderEdit(
|
|
|
146
150
|
const target = options.settings.readTargetSettings();
|
|
147
151
|
const patch = buildProviderSetupPatch(input, {
|
|
148
152
|
providerDefinitions: options.providerDefinitions,
|
|
153
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
149
154
|
});
|
|
150
155
|
const updatedProfile = patch.providers[profileName];
|
|
151
156
|
if (!updatedProfile) {
|
|
@@ -46,7 +46,8 @@ function toProviderSetupStepRequest(
|
|
|
46
46
|
return textAction(`provider-setup-${step.key}`, step.title, {
|
|
47
47
|
description,
|
|
48
48
|
placeholder,
|
|
49
|
-
allowEmpty:
|
|
49
|
+
allowEmpty:
|
|
50
|
+
step.defaultValue !== undefined || (step.editOnly === true && step.required !== true),
|
|
50
51
|
masked: step.masked,
|
|
51
52
|
});
|
|
52
53
|
}
|
|
@@ -105,6 +106,7 @@ function completeProviderSetup(
|
|
|
105
106
|
const target = options.settings.readTargetSettings();
|
|
106
107
|
const patch = buildProviderSetupPatch(input, {
|
|
107
108
|
providerDefinitions: options.providerDefinitions,
|
|
109
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
108
110
|
});
|
|
109
111
|
options.settings.writeTargetSettings(mergeProviderPatch(target, patch));
|
|
110
112
|
// #3282 §3: the session's own live setup-mode state (never settings — an ordinary env-default
|
|
@@ -28,6 +28,7 @@ export interface IProviderSetupFlowState {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export interface IProviderSetupFlowOptions {
|
|
31
|
+
includeEditOnlySteps?: boolean;
|
|
31
32
|
existingProfileNames?: readonly string[];
|
|
32
33
|
initialValues?: Partial<Record<TProviderSetupField, string>>;
|
|
33
34
|
profileName?: string;
|
|
@@ -58,7 +59,9 @@ export function createProviderSetupFlow(
|
|
|
58
59
|
return {
|
|
59
60
|
type,
|
|
60
61
|
steps: applyProviderSetupInitialValues(
|
|
61
|
-
getProviderSetupSteps(definition)
|
|
62
|
+
getProviderSetupSteps(definition).filter(
|
|
63
|
+
(step) => step.editOnly !== true || options.includeEditOnlySteps === true,
|
|
64
|
+
),
|
|
62
65
|
options.initialValues,
|
|
63
66
|
),
|
|
64
67
|
setupHelpLinks: definition.setupHelpLinks ?? [],
|
|
@@ -89,7 +89,7 @@ export async function ensureProviderConfig(
|
|
|
89
89
|
const merged = readMergedProviderSettings(access.sources);
|
|
90
90
|
const selectedSettings =
|
|
91
91
|
ctx.provider !== undefined ? { ...merged, currentProvider: ctx.provider } : merged;
|
|
92
|
-
if (checkSettingsDocument(selectedSettings, providerDefinitions) === 'valid') {
|
|
92
|
+
if (checkSettingsDocument(selectedSettings, providerDefinitions, ctx.env ?? options.env) === 'valid') {
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
// Zero-config startup: a recognized provider env key with complete definition defaults
|
|
@@ -114,7 +114,7 @@ export async function ensureProviderConfig(
|
|
|
114
114
|
const updated = readMergedProviderSettings(access.sources);
|
|
115
115
|
const updatedSettings =
|
|
116
116
|
ctx.provider !== undefined ? { ...updated, currentProvider: ctx.provider } : updated;
|
|
117
|
-
if (checkSettingsDocument(updatedSettings, providerDefinitions) !== 'valid') {
|
|
117
|
+
if (checkSettingsDocument(updatedSettings, providerDefinitions, ctx.env ?? options.env) !== 'valid') {
|
|
118
118
|
throw new ProviderConfigError(options.formatError(providerDefinitions));
|
|
119
119
|
}
|
|
120
120
|
}
|