Skip to content

Commit 19070a3

Browse files
committed
feat(cli): add bulk extension enable/disable (#24658)
1 parent 0179726 commit 19070a3

6 files changed

Lines changed: 460 additions & 129 deletions

File tree

docs/cli/cli-reference.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,10 @@ These are convenient shortcuts that map to specific models:
9494
| `gemini extensions list` | List all installed extensions | `gemini extensions list` |
9595
| `gemini extensions update <name>` | Update a specific extension | `gemini extensions update my-extension` |
9696
| `gemini extensions update --all` | Update all extensions | `gemini extensions update --all` |
97-
| `gemini extensions enable <name>` | Enable an extension | `gemini extensions enable my-extension` |
98-
| `gemini extensions disable <name>` | Disable an extension | `gemini extensions disable my-extension` |
97+
| `gemini extensions enable <names..>` | Enable one or more extensions | `gemini extensions enable ext-a ext-b` |
98+
| `gemini extensions enable --all` | Enable all installed extensions | `gemini extensions enable --all` |
99+
| `gemini extensions disable <names..>` | Disable one or more extensions | `gemini extensions disable ext-a ext-b` |
100+
| `gemini extensions disable --all` | Disable all installed extensions | `gemini extensions disable --all` |
99101
| `gemini extensions link <path>` | Link local extension for development | `gemini extensions link /path/to/extension` |
100102
| `gemini extensions new <path>` | Create new extension from template | `gemini extensions new ./my-extension` |
101103
| `gemini extensions validate <path>` | Validate extension structure | `gemini extensions validate ./my-extension` |

docs/extensions/reference.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,27 +41,41 @@ To uninstall one or more extensions, use the `uninstall` command:
4141
gemini extensions uninstall <name...>
4242
```
4343

44-
### Disable an extension
44+
### Disable extensions
4545

46-
Extensions are enabled globally by default. You can disable an extension
47-
entirely or for a specific workspace.
46+
Extensions are enabled globally by default. You can disable one or more
47+
extensions entirely or for a specific workspace.
4848

4949
```bash
50-
gemini extensions disable <name> [--scope <scope>]
50+
gemini extensions disable <names..> [--scope <scope>]
5151
```
5252

53-
- `<name>`: The name of the extension to disable.
53+
To disable every installed extension at once, use `--all`:
54+
55+
```bash
56+
gemini extensions disable --all
57+
```
58+
59+
- `<names..>`: One or more extension names to disable.
60+
- `--all`: Disable every installed extension.
5461
- `--scope`: The scope to disable the extension in (`user` or `workspace`).
5562

56-
### Enable an extension
63+
### Enable extensions
64+
65+
Re-enable one or more disabled extensions using the `enable` command:
66+
67+
```bash
68+
gemini extensions enable <names..> [--scope <scope>]
69+
```
5770

58-
Re-enable a disabled extension using the `enable` command:
71+
To enable every installed extension at once, use `--all`:
5972

6073
```bash
61-
gemini extensions enable <name> [--scope <scope>]
74+
gemini extensions enable --all
6275
```
6376

64-
- `<name>`: The name of the extension to enable.
77+
- `<names..>`: One or more extension names to enable.
78+
- `--all`: Enable every installed extension.
6579
- `--scope`: The scope to enable the extension in (`user` or `workspace`).
6680

6781
### Update an extension

packages/cli/src/commands/extensions/disable.test.ts

Lines changed: 132 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
type LoadedSettings,
2424
} from '../../config/settings.js';
2525
import { getErrorMessage } from '@google/gemini-cli-core';
26+
import { exitCli } from '../utils.js';
2627

2728
// Mock dependencies
2829
const emitConsoleLog = vi.hoisted(() => vi.fn());
@@ -64,6 +65,7 @@ describe('extensions disable command', () => {
6465
const mockLoadSettings = vi.mocked(loadSettings);
6566
const mockGetErrorMessage = vi.mocked(getErrorMessage);
6667
const mockExtensionManager = vi.mocked(ExtensionManager);
68+
const mockExitCli = vi.mocked(exitCli);
6769

6870
beforeEach(async () => {
6971
vi.clearAllMocks();
@@ -76,6 +78,7 @@ describe('extensions disable command', () => {
7678
mockExtensionManager.prototype.disableExtension = vi
7779
.fn()
7880
.mockResolvedValue(undefined);
81+
mockExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
7982
});
8083

8184
afterEach(() => {
@@ -85,31 +88,28 @@ describe('extensions disable command', () => {
8588
describe('handleDisable', () => {
8689
it.each([
8790
{
88-
name: 'my-extension',
8991
scope: undefined,
9092
expectedScope: SettingScope.User,
9193
expectedLog:
9294
'Extension "my-extension" successfully disabled for scope "undefined".',
9395
},
9496
{
95-
name: 'my-extension',
9697
scope: 'user',
9798
expectedScope: SettingScope.User,
9899
expectedLog:
99100
'Extension "my-extension" successfully disabled for scope "user".',
100101
},
101102
{
102-
name: 'my-extension',
103103
scope: 'workspace',
104104
expectedScope: SettingScope.Workspace,
105105
expectedLog:
106106
'Extension "my-extension" successfully disabled for scope "workspace".',
107107
},
108108
])(
109109
'should disable an extension in the $expectedScope scope when scope is $scope',
110-
async ({ name, scope, expectedScope, expectedLog }) => {
110+
async ({ scope, expectedScope, expectedLog }) => {
111111
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
112-
await handleDisable({ name, scope });
112+
await handleDisable({ names: ['my-extension'], scope });
113113
expect(mockExtensionManager).toHaveBeenCalledWith(
114114
expect.objectContaining({
115115
workspaceDir: '/test/dir',
@@ -120,39 +120,95 @@ describe('extensions disable command', () => {
120120
).toHaveBeenCalled();
121121
expect(
122122
mockExtensionManager.prototype.disableExtension,
123-
).toHaveBeenCalledWith(name, expectedScope);
123+
).toHaveBeenCalledWith('my-extension', expectedScope);
124124
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
125125
mockCwd.mockRestore();
126126
},
127127
);
128128

129-
it('should log an error message and exit with code 1 when extension disabling fails', async () => {
130-
const mockProcessExit = vi
131-
.spyOn(process, 'exit')
132-
.mockImplementation((() => {}) as (
133-
code?: string | number | null | undefined,
134-
) => never);
129+
it('should disable multiple extensions when given a list of names', async () => {
130+
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
131+
await handleDisable({ names: ['ext-a', 'ext-b'] });
132+
133+
expect(
134+
mockExtensionManager.prototype.disableExtension,
135+
).toHaveBeenCalledWith('ext-a', SettingScope.User);
136+
expect(
137+
mockExtensionManager.prototype.disableExtension,
138+
).toHaveBeenCalledWith('ext-b', SettingScope.User);
139+
mockCwd.mockRestore();
140+
});
141+
142+
it('should dedupe duplicate names', async () => {
143+
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
144+
await handleDisable({ names: ['ext-a', 'ext-a'] });
145+
146+
expect(
147+
mockExtensionManager.prototype.disableExtension,
148+
).toHaveBeenCalledTimes(1);
149+
mockCwd.mockRestore();
150+
});
151+
152+
it('should disable every installed extension when --all is set', async () => {
153+
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
154+
mockExtensionManager.prototype.getExtensions = vi
155+
.fn()
156+
.mockReturnValue([{ name: 'ext-a' }, { name: 'ext-b' }]);
157+
158+
await handleDisable({ all: true });
159+
160+
expect(
161+
mockExtensionManager.prototype.disableExtension,
162+
).toHaveBeenCalledWith('ext-a', SettingScope.User);
163+
expect(
164+
mockExtensionManager.prototype.disableExtension,
165+
).toHaveBeenCalledWith('ext-b', SettingScope.User);
166+
mockCwd.mockRestore();
167+
});
168+
169+
it('should log a message and return when --all is set with no installed extensions', async () => {
170+
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
171+
mockExtensionManager.prototype.getExtensions = vi
172+
.fn()
173+
.mockReturnValue([]);
174+
175+
await handleDisable({ all: true });
176+
177+
expect(emitConsoleLog).toHaveBeenCalledWith(
178+
'log',
179+
'No extensions currently installed.',
180+
);
181+
expect(
182+
mockExtensionManager.prototype.disableExtension,
183+
).not.toHaveBeenCalled();
184+
mockCwd.mockRestore();
185+
});
186+
187+
it('should log each error and call exitCli(1) when disabling fails', async () => {
188+
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
135189
const error = new Error('Disable failed');
136190
(
137191
mockExtensionManager.prototype.disableExtension as Mock
138192
).mockRejectedValue(error);
139193
mockGetErrorMessage.mockReturnValue('Disable failed message');
140-
await handleDisable({ name: 'my-extension' });
194+
195+
await handleDisable({ names: ['my-extension'] });
196+
141197
expect(emitConsoleLog).toHaveBeenCalledWith(
142198
'error',
143-
'Disable failed message',
199+
'Failed to disable "my-extension": Disable failed message',
144200
);
145-
expect(mockProcessExit).toHaveBeenCalledWith(1);
146-
mockProcessExit.mockRestore();
201+
expect(mockExitCli).toHaveBeenCalledWith(1);
202+
mockCwd.mockRestore();
147203
});
148204
});
149205

150206
describe('disableCommand', () => {
151207
const command = disableCommand;
152208

153209
it('should have correct command and describe', () => {
154-
expect(command.command).toBe('disable [--scope] <name>');
155-
expect(command.describe).toBe('Disables an extension.');
210+
expect(command.command).toBe('disable [names..]');
211+
expect(command.describe).toBe('Disables one or more extensions.');
156212
});
157213

158214
describe('builder', () => {
@@ -176,10 +232,17 @@ describe('extensions disable command', () => {
176232
(command.builder as (yargs: Argv) => Argv)(
177233
yargsMock as unknown as Argv,
178234
);
179-
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
180-
describe: 'The name of the extension to disable.',
181-
type: 'string',
182-
});
235+
expect(yargsMock.positional).toHaveBeenCalledWith(
236+
'names',
237+
expect.objectContaining({
238+
type: 'string',
239+
array: true,
240+
}),
241+
);
242+
expect(yargsMock.option).toHaveBeenCalledWith(
243+
'all',
244+
expect.objectContaining({ type: 'boolean' }),
245+
);
183246
expect(yargsMock.option).toHaveBeenCalledWith('scope', {
184247
describe: 'The scope to disable the extension in.',
185248
type: 'string',
@@ -188,6 +251,16 @@ describe('extensions disable command', () => {
188251
expect(yargsMock.check).toHaveBeenCalled();
189252
});
190253

254+
it('check function should throw when neither names nor --all is provided', () => {
255+
(command.builder as (yargs: Argv) => Argv)(
256+
yargsMock as unknown as Argv,
257+
);
258+
const checkCallback = yargsMock.check.mock.calls[0][0];
259+
expect(() => checkCallback({})).toThrow(
260+
/at least one extension name to disable/,
261+
);
262+
});
263+
191264
it('check function should throw for invalid scope', () => {
192265
(command.builder as (yargs: Argv) => Argv)(
193266
yargsMock as unknown as Argv,
@@ -198,7 +271,7 @@ describe('extensions disable command', () => {
198271
)
199272
.map((s) => s.toLowerCase())
200273
.join(', ')}.`;
201-
expect(() => checkCallback({ scope: 'invalid' })).toThrow(
274+
expect(() => checkCallback({ all: true, scope: 'invalid' })).toThrow(
202275
expectedError,
203276
);
204277
});
@@ -210,20 +283,22 @@ describe('extensions disable command', () => {
210283
yargsMock as unknown as Argv,
211284
);
212285
const checkCallback = yargsMock.check.mock.calls[0][0];
213-
expect(checkCallback({ scope })).toBe(true);
286+
expect(checkCallback({ all: true, scope })).toBe(true);
214287
},
215288
);
216289
});
217290

218291
it('handler should trigger extension disabling', async () => {
219292
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
220293
interface TestArgv {
221-
name: string;
294+
names: string[];
295+
all: boolean;
222296
scope: string;
223297
[key: string]: unknown;
224298
}
225299
const argv: TestArgv = {
226-
name: 'test-ext',
300+
names: ['test-ext'],
301+
all: false,
227302
scope: 'workspace',
228303
_: [],
229304
$0: '',
@@ -246,5 +321,36 @@ describe('extensions disable command', () => {
246321
);
247322
mockCwd.mockRestore();
248323
});
324+
325+
it('handler should normalize a scalar string positional to a single-element array', async () => {
326+
// Regression: yargs may pass `names` as a bare string when only one
327+
// positional is provided. The handler must wrap it in an array, not let
328+
// it be iterated character-by-character.
329+
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
330+
interface TestArgv {
331+
names: string;
332+
all: boolean;
333+
scope: string;
334+
[key: string]: unknown;
335+
}
336+
const argv: TestArgv = {
337+
names: 'conductor',
338+
all: false,
339+
scope: 'user',
340+
_: [],
341+
$0: '',
342+
};
343+
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
344+
argv,
345+
);
346+
347+
expect(
348+
mockExtensionManager.prototype.disableExtension,
349+
).toHaveBeenCalledTimes(1);
350+
expect(
351+
mockExtensionManager.prototype.disableExtension,
352+
).toHaveBeenCalledWith('conductor', SettingScope.User);
353+
mockCwd.mockRestore();
354+
});
249355
});
250356
});

0 commit comments

Comments
 (0)