@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
|
@@ -142,6 +142,84 @@ describe('what the operator is told', () => {
|
|
|
142
142
|
});
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
describe('linked devices of the device mesh', () => {
|
|
146
|
+
const DEVICE = 'D'.repeat(43);
|
|
147
|
+
|
|
148
|
+
function hostWithDevices(
|
|
149
|
+
peers: readonly TPeerSummary[],
|
|
150
|
+
devices: readonly { deviceId: string; name?: string; locality: 'same-host' | 'another-host' }[],
|
|
151
|
+
): ICommandHostAdapterAccess {
|
|
152
|
+
return {
|
|
153
|
+
getCommandHostAdapters: () => ({
|
|
154
|
+
localPeers: { list: () => peers, ownSessionId: () => OWN, listDevices: () => devices },
|
|
155
|
+
}),
|
|
156
|
+
} as ICommandHostAdapterAccess;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it('lists a linked device beside the sessions on this host, addressable by its id', async () => {
|
|
160
|
+
const result = await executePeersCommand(
|
|
161
|
+
hostWithDevices(
|
|
162
|
+
[
|
|
163
|
+
{ sessionId: OWN, liveness: 'alive' },
|
|
164
|
+
{ sessionId: 'session-other', liveness: 'alive' },
|
|
165
|
+
],
|
|
166
|
+
[{ deviceId: DEVICE, name: 'desktop', locality: 'another-host' }],
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
expect(result.success).toBe(true);
|
|
170
|
+
expect(result.message).toContain('session-other');
|
|
171
|
+
expect(result.message).toMatch(new RegExp(`${DEVICE}\\s+desktop\\s+on another machine`));
|
|
172
|
+
expect(result.message).toContain('/peers send <session-or-device-id>');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('lists a linked device even when this is the only session on this host', async () => {
|
|
176
|
+
const result = await executePeersCommand(
|
|
177
|
+
hostWithDevices(
|
|
178
|
+
[{ sessionId: OWN, liveness: 'alive' }],
|
|
179
|
+
[{ deviceId: DEVICE, locality: 'same-host' }],
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
expect(result.message).not.toContain('No other live session');
|
|
183
|
+
expect(result.message).toMatch(new RegExp(`${DEVICE}\\s+on this machine`));
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('when local discovery is off but the device mesh is not', () => {
|
|
188
|
+
const DEVICE = 'D'.repeat(43);
|
|
189
|
+
|
|
190
|
+
function hostWithoutDiscovery(
|
|
191
|
+
devices: readonly { deviceId: string; name?: string; locality: 'same-host' | 'another-host' }[],
|
|
192
|
+
): ICommandHostAdapterAccess {
|
|
193
|
+
return {
|
|
194
|
+
getCommandHostAdapters: () => ({
|
|
195
|
+
localPeers: {
|
|
196
|
+
list: () => [],
|
|
197
|
+
ownSessionId: () => OWN,
|
|
198
|
+
listDevices: () => devices,
|
|
199
|
+
localDiscoveryOff: 'the rendezvous directory was not admitted',
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
} as ICommandHostAdapterAccess;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it('lists the linked devices and says why sessions on this host are not listed', async () => {
|
|
206
|
+
const result = await executePeersCommand(
|
|
207
|
+
hostWithoutDiscovery([{ deviceId: DEVICE, name: 'desktop', locality: 'another-host' }]),
|
|
208
|
+
);
|
|
209
|
+
expect(result.success).toBe(true);
|
|
210
|
+
expect(result.message).toMatch(new RegExp(`${DEVICE}\\s+desktop\\s+on another machine`));
|
|
211
|
+
expect(result.message).toContain('not admitted');
|
|
212
|
+
expect(result.message).not.toContain('Live sessions');
|
|
213
|
+
expect(result.message).toContain('/peers send <device-id>');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('with no device linked, says discovery is off rather than that nobody is there', async () => {
|
|
217
|
+
const result = await executePeersCommand(hostWithoutDiscovery([]));
|
|
218
|
+
expect(result.message).toContain('not admitted');
|
|
219
|
+
expect(result.message).not.toContain('No other live session');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
145
223
|
describe('when the host wires no discovery', () => {
|
|
146
224
|
it('says the feature is unavailable rather than reporting no peers', async () => {
|
|
147
225
|
// The two are different facts and the difference matters: "nobody is there" invites the operator
|
|
@@ -8,7 +8,7 @@ export function createPeersCommandEntry(): ICommand {
|
|
|
8
8
|
name: 'peers',
|
|
9
9
|
displayName: 'Peers',
|
|
10
10
|
description:
|
|
11
|
-
|
|
11
|
+
"List the other live Robota sessions on this host and the user's other devices linked over the device mesh, send one a message, or send one a copy of a file. Returns the sessions and devices with the ids to address them by (linked devices even when local discovery is off and sessions on this host cannot be listed), or how a send ended. User-only: the model cannot run it; it answers a peer's message with its reply tool, and when the user wants to reach another session or device, suggest they run `/peers`.",
|
|
12
12
|
source: 'peers',
|
|
13
13
|
// The model does not enumerate the operator's other sessions. Discovery is an operator-facing
|
|
14
14
|
// view of who is at the machine, which is a fact about the person and not about the task.
|
|
@@ -33,6 +33,16 @@ function describe(peer: TPeerSummary, ownSessionId: string): string {
|
|
|
33
33
|
return ` ${peer.sessionId}${peer.name ? ` ${peer.name}` : ''} status ${status}${self ? '' : workspace(peer)}${liveness}${self}`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
type TDeviceSummary = ReturnType<
|
|
37
|
+
NonNullable<NonNullable<ICommandHostAdapters['localPeers']>['listDevices']>
|
|
38
|
+
>[number];
|
|
39
|
+
|
|
40
|
+
/** A linked device: its full id, since that is what `send` takes, and where it runs. */
|
|
41
|
+
function describeDevice(device: TDeviceSummary): string {
|
|
42
|
+
const where = device.locality === 'same-host' ? 'on this machine' : 'on another machine';
|
|
43
|
+
return ` ${device.deviceId}${device.name ? ` ${device.name}` : ''} ${where}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
36
46
|
/** The relation this session verified. A claim it could not confirm is named as such, not shown. */
|
|
37
47
|
function workspace(peer: TPeerSummary): string {
|
|
38
48
|
if (peer.workspaceClaim === 'mismatched') return ' workspace claim mismatched, not believed';
|
|
@@ -165,6 +175,21 @@ export async function executePeersCommand(
|
|
|
165
175
|
const sendVerb = /^send(?=\s|$)/.exec(trimmed);
|
|
166
176
|
if (sendVerb !== null) return executeSend(adapter, trimmed.slice(sendVerb[0].length));
|
|
167
177
|
|
|
178
|
+
const devices = adapter.listDevices?.() ?? [];
|
|
179
|
+
const discoveryOff = adapter.localDiscoveryOff;
|
|
180
|
+
if (discoveryOff !== undefined) {
|
|
181
|
+
const off = `Sessions on this host are not listed: local peer discovery is off for this session (${discoveryOff}).`;
|
|
182
|
+
return {
|
|
183
|
+
message:
|
|
184
|
+
devices.length === 0
|
|
185
|
+
? `${off}\nNo device is linked right now.`
|
|
186
|
+
: `Linked devices:\n${devices.map(describeDevice).join('\n')}\n\n${off}` +
|
|
187
|
+
`\n\nSend to one: /peers send <device-id> <message>` +
|
|
188
|
+
`\nSend a file: /peers send-file <device-id> <path>`,
|
|
189
|
+
success: true,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
168
193
|
const own = adapter.ownSessionId();
|
|
169
194
|
// The workspace-judged listing when the host has one; it reads git, so only this view asks for it.
|
|
170
195
|
const peers = (
|
|
@@ -172,7 +197,7 @@ export async function executePeersCommand(
|
|
|
172
197
|
).filter(addressable);
|
|
173
198
|
const others = peers.filter((peer) => peer.sessionId !== own);
|
|
174
199
|
|
|
175
|
-
if (others.length === 0) {
|
|
200
|
+
if (others.length === 0 && devices.length === 0) {
|
|
176
201
|
return {
|
|
177
202
|
message:
|
|
178
203
|
'No other live session is announced. Start a second session on this host, as this user, ' +
|
|
@@ -181,11 +206,15 @@ export async function executePeersCommand(
|
|
|
181
206
|
};
|
|
182
207
|
}
|
|
183
208
|
|
|
184
|
-
const
|
|
209
|
+
const sections = [`Live sessions:\n${peers.map((peer) => describe(peer, own)).join('\n')}`];
|
|
210
|
+
if (devices.length > 0) {
|
|
211
|
+
sections.push(`Linked devices:\n${devices.map(describeDevice).join('\n')}`);
|
|
212
|
+
}
|
|
213
|
+
const target = devices.length > 0 ? '<session-or-device-id>' : '<session-id>';
|
|
185
214
|
return {
|
|
186
215
|
message:
|
|
187
|
-
|
|
188
|
-
|
|
216
|
+
`${sections.join('\n\n')}\n\nSend to one: /peers send ${target} <message>` +
|
|
217
|
+
`\nSend a file: /peers send-file ${target} <path>`,
|
|
189
218
|
success: true,
|
|
190
219
|
};
|
|
191
220
|
}
|
|
@@ -46,7 +46,10 @@ function createCommandSessionRuntime() {
|
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function createCommandHostContext(
|
|
49
|
+
function createCommandHostContext(
|
|
50
|
+
adapter?: ICommandPluginAdapter,
|
|
51
|
+
locality?: 'local' | 'remote',
|
|
52
|
+
) {
|
|
50
53
|
const checkpoint = {
|
|
51
54
|
id: 'checkpoint_1',
|
|
52
55
|
sessionId: 'session_1',
|
|
@@ -66,6 +69,7 @@ function createCommandHostContext(adapter?: ICommandPluginAdapter) {
|
|
|
66
69
|
}),
|
|
67
70
|
getAutoCompactThreshold: () => 0.8,
|
|
68
71
|
getCommandHostAdapters: () => (adapter === undefined ? {} : { plugin: adapter }),
|
|
72
|
+
...(locality ? { getCommandSurfaceLocality: () => locality } : {}),
|
|
69
73
|
compactContext: async () => undefined,
|
|
70
74
|
getCwd: () => '/workspace',
|
|
71
75
|
listCommands: () => [],
|
|
@@ -186,6 +190,82 @@ describe('executePluginCommand', () => {
|
|
|
186
190
|
message: 'Usage: /plugin disable <name>@<marketplace>',
|
|
187
191
|
});
|
|
188
192
|
});
|
|
193
|
+
|
|
194
|
+
it('lists installed plugins through the adapter, for the Settings screen (#3282 §4 part b-2)', async () => {
|
|
195
|
+
const adapter = createPluginAdapter({
|
|
196
|
+
listInstalled: vi.fn().mockResolvedValue([
|
|
197
|
+
{ name: 'formatter@robota', description: 'Formats code.', enabled: true },
|
|
198
|
+
{ name: 'linter@robota', description: 'Lints code.', enabled: false },
|
|
199
|
+
]),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const result = await executePluginCommand(createCommandHostContext(adapter), 'list');
|
|
203
|
+
|
|
204
|
+
expect(result.success).toBe(true);
|
|
205
|
+
expect(result.data).toEqual({
|
|
206
|
+
plugins: [
|
|
207
|
+
{ name: 'formatter@robota', description: 'Formats code.', enabled: true },
|
|
208
|
+
{ name: 'linter@robota', description: 'Lints code.', enabled: false },
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
expect(result.message).toContain('formatter@robota');
|
|
212
|
+
expect(result.message).toContain('linter@robota (disabled)');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('no plugins installed reports a plain empty list', async () => {
|
|
216
|
+
const adapter = createPluginAdapter();
|
|
217
|
+
const result = await executePluginCommand(createCommandHostContext(adapter), 'list');
|
|
218
|
+
expect(result).toEqual({
|
|
219
|
+
success: true,
|
|
220
|
+
message: 'No plugins are installed.',
|
|
221
|
+
data: { plugins: [] },
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('install/uninstall stay local-surface-only (#3282 §4 part b-2)', () => {
|
|
226
|
+
it('runs install from a local surface', async () => {
|
|
227
|
+
const adapter = createPluginAdapter();
|
|
228
|
+
const result = await executePluginCommand(
|
|
229
|
+
createCommandHostContext(adapter, 'local'),
|
|
230
|
+
'install demo@community',
|
|
231
|
+
);
|
|
232
|
+
expect(result.success).toBe(true);
|
|
233
|
+
expect(adapter.install).toHaveBeenCalledWith('demo@community');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('refuses install from a remote surface with a plain message, writing nothing', async () => {
|
|
237
|
+
const adapter = createPluginAdapter();
|
|
238
|
+
const result = await executePluginCommand(
|
|
239
|
+
createCommandHostContext(adapter, 'remote'),
|
|
240
|
+
'install demo@community',
|
|
241
|
+
);
|
|
242
|
+
expect(result.success).toBe(false);
|
|
243
|
+
expect(result.message).toMatch(/only works from the desktop app or the page opened here/);
|
|
244
|
+
expect(result.message).toMatch(/not from a remote device/);
|
|
245
|
+
expect(adapter.install).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('refuses uninstall from a remote surface with a plain message, writing nothing', async () => {
|
|
249
|
+
const adapter = createPluginAdapter();
|
|
250
|
+
const result = await executePluginCommand(
|
|
251
|
+
createCommandHostContext(adapter, 'remote'),
|
|
252
|
+
'uninstall demo@community',
|
|
253
|
+
);
|
|
254
|
+
expect(result.success).toBe(false);
|
|
255
|
+
expect(result.message).toMatch(/not from a remote device/);
|
|
256
|
+
expect(adapter.uninstall).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('never refuses enable/disable/reload from a remote surface — only install/uninstall run new code', async () => {
|
|
260
|
+
const adapter = createPluginAdapter();
|
|
261
|
+
const enableResult = await executePluginCommand(
|
|
262
|
+
createCommandHostContext(adapter, 'remote'),
|
|
263
|
+
'enable demo@community',
|
|
264
|
+
);
|
|
265
|
+
expect(enableResult.success).toBe(true);
|
|
266
|
+
expect(adapter.enable).toHaveBeenCalledWith('demo@community');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
189
269
|
});
|
|
190
270
|
|
|
191
271
|
describe('executeReloadPluginsCommand', () => {
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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: {
|