SaylorTwift HF Staff commited on
Commit
84aa3bf
·
verified ·
1 Parent(s): 7421850

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap +35 -0
  2. packages/cli/src/__snapshots__/nonInteractiveCliAgentSession.test.ts.snap +35 -0
  3. packages/cli/src/acp/README.md +81 -0
  4. packages/cli/src/acp/acpCommandHandler.test.ts +35 -0
  5. packages/cli/src/acp/acpCommandHandler.ts +138 -0
  6. packages/cli/src/acp/acpErrors.test.ts +45 -0
  7. packages/cli/src/acp/acpErrors.ts +44 -0
  8. packages/cli/src/acp/acpFileSystemService.test.ts +236 -0
  9. packages/cli/src/acp/acpFileSystemService.ts +88 -0
  10. packages/cli/src/acp/acpResume.test.ts +313 -0
  11. packages/cli/src/acp/acpRpcDispatcher.test.ts +339 -0
  12. packages/cli/src/acp/acpRpcDispatcher.ts +236 -0
  13. packages/cli/src/acp/acpSession.test.ts +1144 -0
  14. packages/cli/src/acp/acpSession.ts +1527 -0
  15. packages/cli/src/acp/acpSessionManager.test.ts +382 -0
  16. packages/cli/src/acp/acpSessionManager.ts +343 -0
  17. packages/cli/src/acp/acpStdioTransport.ts +35 -0
  18. packages/cli/src/acp/acpUtils.ts +365 -0
  19. packages/cli/src/acp/commands/about.ts +74 -0
  20. packages/cli/src/acp/commands/commandRegistry.ts +33 -0
  21. packages/cli/src/acp/commands/extensions.test.ts +100 -0
  22. packages/cli/src/acp/commands/extensions.ts +448 -0
  23. packages/cli/src/acp/commands/help.test.ts +53 -0
  24. packages/cli/src/acp/commands/help.ts +50 -0
  25. packages/cli/src/acp/commands/init.ts +62 -0
  26. packages/cli/src/acp/commands/memory.ts +142 -0
  27. packages/cli/src/acp/commands/restore.test.ts +244 -0
  28. packages/cli/src/acp/commands/restore.ts +179 -0
  29. packages/cli/src/acp/commands/types.ts +40 -0
  30. packages/cli/src/commands/extensions.test.tsx +92 -0
  31. packages/cli/src/commands/extensions.tsx +47 -0
  32. packages/cli/src/commands/extensions/configure.test.ts +290 -0
  33. packages/cli/src/commands/extensions/configure.ts +98 -0
  34. packages/cli/src/commands/extensions/disable.test.ts +250 -0
  35. packages/cli/src/commands/extensions/disable.ts +88 -0
  36. packages/cli/src/commands/extensions/enable.test.ts +280 -0
  37. packages/cli/src/commands/extensions/enable.ts +115 -0
  38. packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore +26 -0
  39. packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json +5 -0
  40. packages/cli/src/commands/extensions/examples/mcp-server/.gitignore +26 -0
  41. packages/cli/src/commands/extensions/examples/mcp-server/README.md +35 -0
  42. packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json +11 -0
  43. packages/cli/src/commands/extensions/install.test.ts +464 -0
  44. packages/cli/src/commands/extensions/install.ts +228 -0
  45. packages/cli/src/commands/extensions/link.test.ts +181 -0
  46. packages/cli/src/commands/extensions/link.ts +89 -0
  47. packages/cli/src/commands/extensions/list.test.ts +179 -0
  48. packages/cli/src/commands/extensions/list.ts +69 -0
  49. packages/cli/src/commands/extensions/new.test.ts +94 -0
  50. packages/cli/src/commands/extensions/new.ts +108 -0
packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
4
+ "{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
5
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
6
+ {"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
7
+ {"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
8
+ "
9
+ `;
10
+
11
+ exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
12
+ "{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
13
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
14
+ {"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
15
+ {"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
16
+ "
17
+ `;
18
+
19
+ exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
20
+ "{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
21
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
22
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
23
+ {"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
24
+ {"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
25
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
26
+ {"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
27
+ "
28
+ `;
29
+
30
+ exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
31
+ "Use mock tool
32
+ Use mock tool again
33
+ Finished.
34
+ "
35
+ `;
packages/cli/src/__snapshots__/nonInteractiveCliAgentSession.test.ts.snap ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
4
+ "{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
5
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
6
+ {"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
7
+ {"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
8
+ "
9
+ `;
10
+
11
+ exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
12
+ "{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
13
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
14
+ {"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
15
+ {"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
16
+ "
17
+ `;
18
+
19
+ exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
20
+ "{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
21
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
22
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
23
+ {"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
24
+ {"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
25
+ {"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
26
+ {"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
27
+ "
28
+ `;
29
+
30
+ exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
31
+ "Use mock tool
32
+ Use mock tool again
33
+ Finished.
34
+ "
35
+ `;
packages/cli/src/acp/README.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent Client Protocol (ACP) Implementation
2
+
3
+ This directory contains the implementation of the Agent Client Protocol (ACP)
4
+ for the Gemini CLI. The ACP allows external clients (like IDE extensions) to
5
+ communicate with the Gemini CLI agent over a structured JSON-RPC based protocol.
6
+
7
+ ## Directory Structure
8
+
9
+ Following Phase 1 of the modularization refactor, the ACP client is organized
10
+ into the following specialized modules, all sharing the `acp` prefix for
11
+ consistency:
12
+
13
+ - **[acpStdioTransport.ts](./acpStdioTransport.ts)**: Handles raw I/O. It sets
14
+ up the Web streams for standard input/output and creates the
15
+ `AgentSideConnection` using line-delimited JSON (ndjson).
16
+ - **[acpRpcDispatcher.ts](./acpRpcDispatcher.ts)**: Contains the `GeminiAgent`
17
+ class. This is the main entry point for incoming JSON-RPC messages. It
18
+ implements the protocol methods and delegates session-specific work to the
19
+ manager and individual sessions.
20
+ - **[acpSessionManager.ts](./acpSessionManager.ts)**: Manages multi-session
21
+ state. It handles session creation (`newSession`), loading (`loadSession`),
22
+ and configuration, isolating session state from the RPC routing.
23
+ - **[acpSession.ts](./acpSession.ts)**: Manages individual active chat sessions.
24
+ It handles prompt execution, `@path` file resolution, tool execution, command
25
+ interception, and streaming updates back to the client.
26
+ - **[acpUtils.ts](./acpUtils.ts)**: Contains shared helper functions, type
27
+ mappers (e.g., mapping internal tool kinds to ACP kinds), and Zod schemas used
28
+ across the modules.
29
+ - **[acpErrors.ts](./acpErrors.ts)**: Centralized error handling and mapping to
30
+ ACP-compliant error codes.
31
+ - **[acpCommandHandler.ts](./acpCommandHandler.ts)**: Handles interception and
32
+ execution of slash commands (e.g., `/memory`, `/init`) sent via ACP prompts.
33
+ - **[acpFileSystemService.ts](./acpFileSystemService.ts)**: Provides access to
34
+ the file system restricted by the workspace boundaries and permissions.
35
+
36
+ ## Development Instructions
37
+
38
+ ### Running Tests
39
+
40
+ Tests are co-located with the source files:
41
+
42
+ - `acpRpcDispatcher.test.ts`: Tests for initialization, authentication, and
43
+ handler delegation.
44
+ - `acpSessionManager.test.ts`: Tests for session lifecycle and configuration.
45
+ - `acpSession.test.ts`: Tests for prompt loops, tool execution, and @path
46
+ resolution.
47
+ - `acpResume.test.ts`: Integration tests for loading/resuming sessions.
48
+
49
+ To run specific tests, use Vitest with the workspace filter:
50
+
51
+ ```bash
52
+ # General pattern
53
+ npm test -w @google/gemini-cli -- src/acp/<test-file-name>.ts
54
+
55
+ # Example
56
+ npm test -w @google/gemini-cli -- src/acp/acpRpcDispatcher.test.ts
57
+ ```
58
+
59
+ Note: You may need to ensure your environment has Node available. If running in
60
+ a restricted environment, try sourcing NVM first:
61
+
62
+ ```bash
63
+ source ~/.nvm/nvm.sh && nvm use default && npm test -w @google/gemini-cli -- src/acp/acpSession.test.ts
64
+ ```
65
+
66
+ ### Adding New Features
67
+
68
+ - **New RPC Method**: Add the method to `GeminiAgent` in `acpRpcDispatcher.ts`
69
+ and register it in the `AgentSideConnection` setup if necessary.
70
+ - **Session State**: If a feature requires storing state across turns within a
71
+ session, add it to the `Session` class in `acpSession.ts`.
72
+ - **Protocol Helpers**: Add any new mapping or serialization logic to
73
+ `acpUtils.ts`.
74
+
75
+ ### Coding Conventions
76
+
77
+ - **Imports**: Use specific imports and do not import across package boundaries
78
+ using relative paths.
79
+ - **License Headers**: All new files must include the Apache-2.0 license header.
80
+ - **Type Safety**: Avoid using `any` assertions. Use Zod schemas to validate
81
+ untrusted input from the protocol.
packages/cli/src/acp/acpCommandHandler.test.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { CommandHandler } from './acpCommandHandler.js';
8
+ import { describe, it, expect } from 'vitest';
9
+
10
+ describe('CommandHandler', () => {
11
+ it('parses commands correctly', () => {
12
+ const handler = new CommandHandler();
13
+ // @ts-expect-error - testing private method
14
+ const parse = (query: string) => handler.parseSlashCommand(query);
15
+
16
+ const memShow = parse('/memory show');
17
+ expect(memShow.commandToExecute?.name).toBe('memory show');
18
+ expect(memShow.args).toBe('');
19
+
20
+ const memList = parse('/memory list');
21
+ expect(memList.commandToExecute?.name).toBe('memory list');
22
+
23
+ const extList = parse('/extensions list');
24
+ expect(extList.commandToExecute?.name).toBe('extensions list');
25
+
26
+ const init = parse('/init');
27
+ expect(init.commandToExecute?.name).toBe('init');
28
+
29
+ const about = parse('/about');
30
+ expect(about.commandToExecute?.name).toBe('about');
31
+
32
+ const help = parse('/help');
33
+ expect(help.commandToExecute?.name).toBe('help');
34
+ });
35
+ });
packages/cli/src/acp/acpCommandHandler.ts ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { Command, CommandContext } from './commands/types.js';
8
+ import { CommandRegistry } from './commands/commandRegistry.js';
9
+ import { MemoryCommand } from './commands/memory.js';
10
+ import { ExtensionsCommand } from './commands/extensions.js';
11
+ import { InitCommand } from './commands/init.js';
12
+ import { RestoreCommand } from './commands/restore.js';
13
+ import { AboutCommand } from './commands/about.js';
14
+ import { HelpCommand } from './commands/help.js';
15
+
16
+ export class CommandHandler {
17
+ private registry: CommandRegistry;
18
+
19
+ constructor() {
20
+ this.registry = CommandHandler.createRegistry();
21
+ }
22
+
23
+ private static createRegistry(): CommandRegistry {
24
+ const registry = new CommandRegistry();
25
+ registry.register(new MemoryCommand());
26
+ registry.register(new ExtensionsCommand());
27
+ registry.register(new InitCommand());
28
+ registry.register(new RestoreCommand());
29
+ registry.register(new AboutCommand());
30
+ registry.register(new HelpCommand(registry));
31
+ return registry;
32
+ }
33
+
34
+ getAvailableCommands(): Array<{ name: string; description: string }> {
35
+ return this.registry.getAllCommands().map((cmd) => ({
36
+ name: cmd.name,
37
+ description: cmd.description,
38
+ }));
39
+ }
40
+
41
+ /**
42
+ * Parses and executes a command string if it matches a registered command.
43
+ * Returns true if a command was handled, false otherwise.
44
+ */
45
+ async handleCommand(
46
+ commandText: string,
47
+ context: CommandContext,
48
+ ): Promise<boolean> {
49
+ const { commandToExecute, args } = this.parseSlashCommand(commandText);
50
+
51
+ if (commandToExecute) {
52
+ await this.runCommand(commandToExecute, args, context);
53
+ return true;
54
+ }
55
+
56
+ return false;
57
+ }
58
+
59
+ private async runCommand(
60
+ commandToExecute: Command,
61
+ args: string,
62
+ context: CommandContext,
63
+ ): Promise<void> {
64
+ try {
65
+ const result = await commandToExecute.execute(
66
+ context,
67
+ args ? args.split(/\s+/) : [],
68
+ );
69
+
70
+ let messageContent = '';
71
+ if (typeof result.data === 'string') {
72
+ messageContent = result.data;
73
+ } else if (
74
+ typeof result.data === 'object' &&
75
+ result.data !== null &&
76
+ 'content' in result.data
77
+ ) {
78
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
79
+ messageContent = (result.data as Record<string, any>)[
80
+ 'content'
81
+ ] as string;
82
+ } else {
83
+ messageContent = JSON.stringify(result.data, null, 2);
84
+ }
85
+
86
+ await context.sendMessage(messageContent);
87
+ } catch (error) {
88
+ const errorMessage =
89
+ error instanceof Error ? error.message : String(error);
90
+ await context.sendMessage(`Error: ${errorMessage}`);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Parses a raw slash command string into its matching headless command and arguments.
96
+ * Mirrors `packages/cli/src/utils/commands.ts` logic.
97
+ */
98
+ private parseSlashCommand(query: string): {
99
+ commandToExecute: Command | undefined;
100
+ args: string;
101
+ } {
102
+ const trimmed = query.trim();
103
+ const parts = trimmed.substring(1).trim().split(/\s+/);
104
+ const commandPath = parts.filter((p) => p);
105
+
106
+ let currentCommands = this.registry.getAllCommands();
107
+ let commandToExecute: Command | undefined;
108
+ let pathIndex = 0;
109
+
110
+ for (const part of commandPath) {
111
+ const foundCommand = currentCommands.find((cmd) => {
112
+ const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
113
+ return (
114
+ cmd.name === part ||
115
+ cmd.name === expectedName ||
116
+ cmd.aliases?.includes(part) ||
117
+ cmd.aliases?.includes(expectedName)
118
+ );
119
+ });
120
+
121
+ if (foundCommand) {
122
+ commandToExecute = foundCommand;
123
+ pathIndex++;
124
+ if (foundCommand.subCommands) {
125
+ currentCommands = foundCommand.subCommands;
126
+ } else {
127
+ break;
128
+ }
129
+ } else {
130
+ break;
131
+ }
132
+ }
133
+
134
+ const args = parts.slice(pathIndex).join(' ');
135
+
136
+ return { commandToExecute, args };
137
+ }
138
+ }
packages/cli/src/acp/acpErrors.test.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { getAcpErrorMessage } from './acpErrors.js';
9
+
10
+ describe('getAcpErrorMessage', () => {
11
+ it('should return plain error message', () => {
12
+ expect(getAcpErrorMessage(new Error('plain error'))).toBe('plain error');
13
+ });
14
+
15
+ it('should parse simple JSON error response', () => {
16
+ const json = JSON.stringify({ error: { message: 'json error' } });
17
+ expect(getAcpErrorMessage(new Error(json))).toBe('json error');
18
+ });
19
+
20
+ it('should parse double-encoded JSON error response', () => {
21
+ const innerJson = JSON.stringify({ error: { message: 'nested error' } });
22
+ const outerJson = JSON.stringify({ error: { message: innerJson } });
23
+ expect(getAcpErrorMessage(new Error(outerJson))).toBe('nested error');
24
+ });
25
+
26
+ it('should parse array-style JSON error response', () => {
27
+ const json = JSON.stringify([{ error: { message: 'array error' } }]);
28
+ expect(getAcpErrorMessage(new Error(json))).toBe('array error');
29
+ });
30
+
31
+ it('should parse JSON with top-level message field', () => {
32
+ const json = JSON.stringify({ message: 'top-level message' });
33
+ expect(getAcpErrorMessage(new Error(json))).toBe('top-level message');
34
+ });
35
+
36
+ it('should handle JSON with trailing newline', () => {
37
+ const json = JSON.stringify({ error: { message: 'newline error' } }) + '\n';
38
+ expect(getAcpErrorMessage(new Error(json))).toBe('newline error');
39
+ });
40
+
41
+ it('should return original message if JSON parsing fails', () => {
42
+ const invalidJson = '{ not-json }';
43
+ expect(getAcpErrorMessage(new Error(invalidJson))).toBe(invalidJson);
44
+ });
45
+ });
packages/cli/src/acp/acpErrors.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { getErrorMessage as getCoreErrorMessage } from '@google/gemini-cli-core';
8
+
9
+ /**
10
+ * Extracts a human-readable error message specifically for ACP (IDE) clients.
11
+ * This function recursively parses JSON error blobs that are common in
12
+ * Google API responses but ugly to display in an IDE's UI.
13
+ */
14
+ export function getAcpErrorMessage(error: unknown): string {
15
+ const coreMessage = getCoreErrorMessage(error);
16
+ return extractRecursiveMessage(coreMessage);
17
+ }
18
+
19
+ function extractRecursiveMessage(input: string): string {
20
+ const trimmed = input.trim();
21
+
22
+ // Attempt to parse JSON error responses (common in Google API errors)
23
+ if (
24
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
25
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))
26
+ ) {
27
+ try {
28
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
29
+ const parsed = JSON.parse(trimmed);
30
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
31
+ const next =
32
+ parsed?.error?.message ||
33
+ parsed?.[0]?.error?.message ||
34
+ parsed?.message;
35
+
36
+ if (next && typeof next === 'string' && next !== input) {
37
+ return extractRecursiveMessage(next);
38
+ }
39
+ } catch {
40
+ // Fall back to original string if parsing fails
41
+ }
42
+ }
43
+ return input;
44
+ }
packages/cli/src/acp/acpFileSystemService.test.ts ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ describe,
9
+ it,
10
+ expect,
11
+ vi,
12
+ beforeEach,
13
+ afterEach,
14
+ type Mocked,
15
+ } from 'vitest';
16
+ import { AcpFileSystemService } from './acpFileSystemService.js';
17
+ import type { AgentSideConnection } from '@agentclientprotocol/sdk';
18
+ import type { FileSystemService } from '@google/gemini-cli-core';
19
+ import os from 'node:os';
20
+
21
+ vi.mock('node:os', () => ({
22
+ default: {
23
+ homedir: vi.fn(),
24
+ },
25
+ }));
26
+
27
+ describe('AcpFileSystemService', () => {
28
+ let mockConnection: Mocked<AgentSideConnection>;
29
+ let mockFallback: Mocked<FileSystemService>;
30
+ let service: AcpFileSystemService;
31
+
32
+ beforeEach(() => {
33
+ mockConnection = {
34
+ requestPermission: vi.fn(),
35
+ sessionUpdate: vi.fn(),
36
+ writeTextFile: vi.fn(),
37
+ readTextFile: vi.fn(),
38
+ } as unknown as Mocked<AgentSideConnection>;
39
+ mockFallback = {
40
+ readTextFile: vi.fn(),
41
+ writeTextFile: vi.fn(),
42
+ };
43
+ vi.mocked(os.homedir).mockReturnValue('/home/user');
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ describe('readTextFile', () => {
51
+ it.each([
52
+ {
53
+ capability: true,
54
+ path: '/path/to/file',
55
+ desc: 'connection if capability exists and file is inside root',
56
+ setup: () => {
57
+ mockConnection.readTextFile.mockResolvedValue({ content: 'content' });
58
+ },
59
+ verify: () => {
60
+ expect(mockConnection.readTextFile).toHaveBeenCalledWith({
61
+ path: '/path/to/file',
62
+ sessionId: 'session-1',
63
+ });
64
+ expect(mockFallback.readTextFile).not.toHaveBeenCalled();
65
+ },
66
+ },
67
+ {
68
+ capability: false,
69
+ path: '/path/to/file',
70
+ desc: 'fallback if capability missing',
71
+ setup: () => {
72
+ mockFallback.readTextFile.mockResolvedValue('content');
73
+ },
74
+ verify: () => {
75
+ expect(mockFallback.readTextFile).toHaveBeenCalledWith(
76
+ '/path/to/file',
77
+ );
78
+ expect(mockConnection.readTextFile).not.toHaveBeenCalled();
79
+ },
80
+ },
81
+ {
82
+ capability: true,
83
+ path: '/outside/file',
84
+ desc: 'fallback if capability exists but file is outside root',
85
+ setup: () => {
86
+ mockFallback.readTextFile.mockResolvedValue('content');
87
+ },
88
+ verify: () => {
89
+ expect(mockFallback.readTextFile).toHaveBeenCalledWith(
90
+ '/outside/file',
91
+ );
92
+ expect(mockConnection.readTextFile).not.toHaveBeenCalled();
93
+ },
94
+ },
95
+ {
96
+ capability: true,
97
+ path: '/home/user/.gemini/tmp/file.md',
98
+ root: '/home/user',
99
+ desc: 'fallback if file is inside global gemini dir, even if root overlaps',
100
+ setup: () => {
101
+ mockFallback.readTextFile.mockResolvedValue('content');
102
+ },
103
+ verify: () => {
104
+ expect(mockFallback.readTextFile).toHaveBeenCalledWith(
105
+ '/home/user/.gemini/tmp/file.md',
106
+ );
107
+ expect(mockConnection.readTextFile).not.toHaveBeenCalled();
108
+ },
109
+ },
110
+ ])(
111
+ 'should use $desc',
112
+ async ({ capability, path, root, setup, verify }) => {
113
+ service = new AcpFileSystemService(
114
+ mockConnection,
115
+ 'session-1',
116
+ { readTextFile: capability, writeTextFile: true },
117
+ mockFallback,
118
+ root || '/path/to',
119
+ );
120
+ setup();
121
+
122
+ const result = await service.readTextFile(path);
123
+
124
+ expect(result).toBe('content');
125
+ verify();
126
+ },
127
+ );
128
+
129
+ it('should throw normalized ENOENT error when readTextFile encounters "Resource not found"', async () => {
130
+ service = new AcpFileSystemService(
131
+ mockConnection,
132
+ 'session-1',
133
+ { readTextFile: true, writeTextFile: true },
134
+ mockFallback,
135
+ '/path/to',
136
+ );
137
+ mockConnection.readTextFile.mockRejectedValue(
138
+ new Error('Resource not found for document'),
139
+ );
140
+
141
+ await expect(
142
+ service.readTextFile('/path/to/missing'),
143
+ ).rejects.toMatchObject({
144
+ code: 'ENOENT',
145
+ message: 'Resource not found for document',
146
+ });
147
+ });
148
+ });
149
+
150
+ describe('writeTextFile', () => {
151
+ it.each([
152
+ {
153
+ capability: true,
154
+ path: '/path/to/file',
155
+ desc: 'connection if capability exists and file is inside root',
156
+ verify: () => {
157
+ expect(mockConnection.writeTextFile).toHaveBeenCalledWith({
158
+ path: '/path/to/file',
159
+ content: 'content',
160
+ sessionId: 'session-1',
161
+ });
162
+ expect(mockFallback.writeTextFile).not.toHaveBeenCalled();
163
+ },
164
+ },
165
+ {
166
+ capability: false,
167
+ path: '/path/to/file',
168
+ desc: 'fallback if capability missing',
169
+ verify: () => {
170
+ expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
171
+ '/path/to/file',
172
+ 'content',
173
+ );
174
+ expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
175
+ },
176
+ },
177
+ {
178
+ capability: true,
179
+ path: '/outside/file',
180
+ desc: 'fallback if capability exists but file is outside root',
181
+ verify: () => {
182
+ expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
183
+ '/outside/file',
184
+ 'content',
185
+ );
186
+ expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
187
+ },
188
+ },
189
+ {
190
+ capability: true,
191
+ path: '/home/user/.gemini/tmp/file.md',
192
+ root: '/home/user',
193
+ desc: 'fallback if file is inside global gemini dir, even if root overlaps',
194
+ verify: () => {
195
+ expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
196
+ '/home/user/.gemini/tmp/file.md',
197
+ 'content',
198
+ );
199
+ expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
200
+ },
201
+ },
202
+ ])('should use $desc', async ({ capability, path, root, verify }) => {
203
+ service = new AcpFileSystemService(
204
+ mockConnection,
205
+ 'session-1',
206
+ { writeTextFile: capability, readTextFile: true },
207
+ mockFallback,
208
+ root || '/path/to',
209
+ );
210
+
211
+ await service.writeTextFile(path, 'content');
212
+
213
+ verify();
214
+ });
215
+
216
+ it('should throw normalized ENOENT error when writeTextFile encounters "Resource not found"', async () => {
217
+ service = new AcpFileSystemService(
218
+ mockConnection,
219
+ 'session-1',
220
+ { readTextFile: true, writeTextFile: true },
221
+ mockFallback,
222
+ '/path/to',
223
+ );
224
+ mockConnection.writeTextFile.mockRejectedValue(
225
+ new Error('Resource not found for directory'),
226
+ );
227
+
228
+ await expect(
229
+ service.writeTextFile('/path/to/missing', 'content'),
230
+ ).rejects.toMatchObject({
231
+ code: 'ENOENT',
232
+ message: 'Resource not found for directory',
233
+ });
234
+ });
235
+ });
236
+ });
packages/cli/src/acp/acpFileSystemService.ts ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { isWithinRoot, type FileSystemService } from '@google/gemini-cli-core';
8
+ import type * as acp from '@agentclientprotocol/sdk';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ /**
13
+ * ACP client-based implementation of FileSystemService
14
+ */
15
+ export class AcpFileSystemService implements FileSystemService {
16
+ private readonly geminiDir = path.join(os.homedir(), '.gemini');
17
+
18
+ constructor(
19
+ private readonly connection: acp.AgentSideConnection,
20
+ private readonly sessionId: string,
21
+ private readonly capabilities: acp.FileSystemCapabilities,
22
+ private readonly fallback: FileSystemService,
23
+ private readonly root: string,
24
+ ) {}
25
+
26
+ private shouldUseFallback(filePath: string): boolean {
27
+ // Files inside the global CLI directory must always use the native file system,
28
+ // even if the user runs the CLI directly from their home directory (which
29
+ // would make the IDE's project root overlap with the global directory).
30
+ return (
31
+ !isWithinRoot(filePath, this.root) ||
32
+ isWithinRoot(filePath, this.geminiDir)
33
+ );
34
+ }
35
+
36
+ private normalizeFileSystemError(err: unknown): never {
37
+ const errorMessage = err instanceof Error ? err.message : String(err);
38
+ if (
39
+ errorMessage.includes('Resource not found') ||
40
+ errorMessage.includes('ENOENT') ||
41
+ errorMessage.includes('does not exist') ||
42
+ errorMessage.includes('No such file')
43
+ ) {
44
+ const newErr = new Error(errorMessage) as NodeJS.ErrnoException;
45
+ newErr.code = 'ENOENT';
46
+ throw newErr;
47
+ }
48
+ throw err;
49
+ }
50
+
51
+ async readTextFile(filePath: string): Promise<string> {
52
+ if (!this.capabilities.readTextFile || this.shouldUseFallback(filePath)) {
53
+ return this.fallback.readTextFile(filePath);
54
+ }
55
+
56
+ try {
57
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
58
+ const response = await this.connection.readTextFile({
59
+ path: filePath,
60
+ sessionId: this.sessionId,
61
+ });
62
+
63
+ const content: unknown = response.content;
64
+ if (typeof content !== 'string') {
65
+ throw new Error('content must be a string'); // replace with other response type formats when modified in the future
66
+ }
67
+ return content;
68
+ } catch (err: unknown) {
69
+ this.normalizeFileSystemError(err);
70
+ }
71
+ }
72
+
73
+ async writeTextFile(filePath: string, content: string): Promise<void> {
74
+ if (!this.capabilities.writeTextFile || this.shouldUseFallback(filePath)) {
75
+ return this.fallback.writeTextFile(filePath, content);
76
+ }
77
+
78
+ try {
79
+ await this.connection.writeTextFile({
80
+ path: filePath,
81
+ content,
82
+ sessionId: this.sessionId,
83
+ });
84
+ } catch (err: unknown) {
85
+ this.normalizeFileSystemError(err);
86
+ }
87
+ }
88
+ }
packages/cli/src/acp/acpResume.test.ts ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ describe,
9
+ it,
10
+ expect,
11
+ vi,
12
+ beforeEach,
13
+ type Mocked,
14
+ type Mock,
15
+ } from 'vitest';
16
+ import { GeminiAgent } from './acpRpcDispatcher.js';
17
+ import * as acp from '@agentclientprotocol/sdk';
18
+ import {
19
+ ApprovalMode,
20
+ AuthType,
21
+ type Config,
22
+ CoreToolCallStatus,
23
+ } from '@google/gemini-cli-core';
24
+ import { loadCliConfig, type CliArgs } from '../config/config.js';
25
+ import {
26
+ SessionSelector,
27
+ convertSessionToHistoryFormats,
28
+ } from '../utils/sessionUtils.js';
29
+ import { convertSessionToClientHistory } from '@google/gemini-cli-core';
30
+ import type { LoadedSettings } from '../config/settings.js';
31
+ import { waitFor } from '../test-utils/async.js';
32
+
33
+ vi.mock('../config/config.js', () => ({
34
+ loadCliConfig: vi.fn(),
35
+ }));
36
+
37
+ vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
38
+ const actual =
39
+ await importOriginal<typeof import('../utils/sessionUtils.js')>();
40
+ return {
41
+ ...actual,
42
+ SessionSelector: vi.fn(),
43
+ convertSessionToHistoryFormats: vi.fn(),
44
+ };
45
+ });
46
+
47
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
48
+ const actual =
49
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
50
+ return {
51
+ ...actual,
52
+ CoreToolCallStatus: {
53
+ Validating: 'validating',
54
+ Scheduled: 'scheduled',
55
+ Error: 'error',
56
+ Success: 'success',
57
+ Executing: 'executing',
58
+ Cancelled: 'cancelled',
59
+ AwaitingApproval: 'awaiting_approval',
60
+ },
61
+ LlmRole: {
62
+ MAIN: 'main',
63
+ SUBAGENT: 'subagent',
64
+ UTILITY_TOOL: 'utility_tool',
65
+ USER: 'user',
66
+ MODEL: 'model',
67
+ SYSTEM: 'system',
68
+ TOOL: 'tool',
69
+ },
70
+ convertSessionToClientHistory: vi.fn(),
71
+ };
72
+ });
73
+
74
+ describe('GeminiAgent Session Resume', () => {
75
+ let mockConfig: Mocked<Config>;
76
+ let mockSettings: Mocked<LoadedSettings>;
77
+ let mockArgv: CliArgs;
78
+ let mockConnection: Mocked<acp.AgentSideConnection>;
79
+ let agent: GeminiAgent;
80
+
81
+ beforeEach(() => {
82
+ mockConfig = {
83
+ refreshAuth: vi.fn().mockResolvedValue(undefined),
84
+ initialize: vi.fn().mockResolvedValue(undefined),
85
+ getFileSystemService: vi.fn(),
86
+ setFileSystemService: vi.fn(),
87
+ getGeminiClient: vi.fn().mockReturnValue({
88
+ initialize: vi.fn().mockResolvedValue(undefined),
89
+ resumeChat: vi.fn().mockResolvedValue(undefined),
90
+ getChat: vi.fn().mockReturnValue({}),
91
+ }),
92
+ storage: {
93
+ getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
94
+ },
95
+ getPolicyEngine: vi.fn().mockReturnValue({
96
+ addRule: vi.fn(),
97
+ }),
98
+ messageBus: {
99
+ publish: vi.fn(),
100
+ subscribe: vi.fn(),
101
+ unsubscribe: vi.fn(),
102
+ },
103
+ getMessageBus: vi.fn().mockReturnValue({
104
+ publish: vi.fn(),
105
+ subscribe: vi.fn(),
106
+ unsubscribe: vi.fn(),
107
+ }),
108
+ getApprovalMode: vi.fn().mockReturnValue('default'),
109
+ isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
110
+ isPlanEnabled: vi.fn().mockReturnValue(true),
111
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
112
+ getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
113
+ getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
114
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
115
+ toolRegistry: {
116
+ getTool: vi.fn().mockReturnValue({ kind: 'read' }),
117
+ },
118
+ get config() {
119
+ return this;
120
+ },
121
+ } as unknown as Mocked<Config>;
122
+ mockSettings = {
123
+ merged: {
124
+ security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
125
+ mcpServers: {},
126
+ },
127
+ setValue: vi.fn(),
128
+ } as unknown as Mocked<LoadedSettings>;
129
+ mockArgv = {} as unknown as CliArgs;
130
+ mockConnection = {
131
+ sessionUpdate: vi.fn().mockResolvedValue(undefined),
132
+ } as unknown as Mocked<acp.AgentSideConnection>;
133
+
134
+ (loadCliConfig as Mock).mockResolvedValue(mockConfig);
135
+
136
+ agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
137
+ });
138
+
139
+ it('should advertise loadSession capability', async () => {
140
+ const response = await agent.initialize({
141
+ protocolVersion: acp.PROTOCOL_VERSION,
142
+ });
143
+ expect(response.agentCapabilities?.loadSession).toBe(true);
144
+ });
145
+
146
+ it('should load a session, resume chat, and stream all message types', async () => {
147
+ const sessionId = 'existing-session-id';
148
+ const sessionData = {
149
+ sessionId,
150
+ messages: [
151
+ { type: 'user', content: [{ text: 'Hello' }] },
152
+ {
153
+ type: 'gemini',
154
+ content: [{ text: 'Hi there' }],
155
+ thoughts: [{ subject: 'Thinking', description: 'about greeting' }],
156
+ toolCalls: [
157
+ {
158
+ id: 'call-1',
159
+ name: 'test_tool',
160
+ displayName: 'Test Tool',
161
+ status: CoreToolCallStatus.Success,
162
+ resultDisplay: 'Tool output',
163
+ },
164
+ ],
165
+ },
166
+ {
167
+ type: 'gemini',
168
+ content: [{ text: 'Trying a write' }],
169
+ toolCalls: [
170
+ {
171
+ id: 'call-2',
172
+ name: 'write_file',
173
+ displayName: 'Write File',
174
+ status: CoreToolCallStatus.Error,
175
+ resultDisplay: 'Permission denied',
176
+ },
177
+ ],
178
+ },
179
+ ],
180
+ };
181
+
182
+ (SessionSelector as unknown as Mock).mockImplementation(() => ({
183
+ resolveSession: vi.fn().mockResolvedValue({
184
+ sessionData,
185
+ sessionPath: '/path/to/session.json',
186
+ }),
187
+ }));
188
+
189
+ const mockClientHistory = [
190
+ { role: 'user', parts: [{ text: 'Hello' }] },
191
+ { role: 'model', parts: [{ text: 'Hi there' }] },
192
+ ];
193
+ (convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
194
+ uiHistory: [],
195
+ });
196
+ (convertSessionToClientHistory as unknown as Mock).mockReturnValue(
197
+ mockClientHistory,
198
+ );
199
+
200
+ const response = await agent.loadSession({
201
+ sessionId,
202
+ cwd: '/tmp',
203
+ mcpServers: [],
204
+ });
205
+
206
+ expect(response).toEqual({
207
+ modes: {
208
+ availableModes: [
209
+ {
210
+ id: ApprovalMode.DEFAULT,
211
+ name: 'Default',
212
+ description: 'Prompts for approval',
213
+ },
214
+ {
215
+ id: ApprovalMode.AUTO_EDIT,
216
+ name: 'Auto Edit',
217
+ description: 'Auto-approves edit tools',
218
+ },
219
+ {
220
+ id: ApprovalMode.YOLO,
221
+ name: 'YOLO',
222
+ description: 'Auto-approves all tools',
223
+ },
224
+ {
225
+ id: ApprovalMode.PLAN,
226
+ name: 'Plan',
227
+ description: 'Read-only mode',
228
+ },
229
+ ],
230
+ currentModeId: ApprovalMode.DEFAULT,
231
+ },
232
+ models: {
233
+ availableModels: expect.any(Array) as unknown,
234
+ currentModelId: 'gemini-pro',
235
+ },
236
+ });
237
+
238
+ // Verify resumeChat received the correct arguments
239
+ expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
240
+ mockClientHistory,
241
+ expect.objectContaining({
242
+ conversation: sessionData,
243
+ filePath: '/path/to/session.json',
244
+ }),
245
+ );
246
+
247
+ await waitFor(() => {
248
+ // User message
249
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
250
+ expect.objectContaining({
251
+ update: expect.objectContaining({
252
+ sessionUpdate: 'user_message_chunk',
253
+ content: expect.objectContaining({ text: 'Hello' }),
254
+ }),
255
+ }),
256
+ );
257
+
258
+ // Agent thought
259
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
260
+ expect.objectContaining({
261
+ update: expect.objectContaining({
262
+ sessionUpdate: 'agent_thought_chunk',
263
+ content: expect.objectContaining({
264
+ text: '**Thinking**\nabout greeting',
265
+ }),
266
+ }),
267
+ }),
268
+ );
269
+
270
+ // Agent message
271
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
272
+ expect.objectContaining({
273
+ update: expect.objectContaining({
274
+ sessionUpdate: 'agent_message_chunk',
275
+ content: expect.objectContaining({ text: 'Hi there' }),
276
+ }),
277
+ }),
278
+ );
279
+
280
+ // Successful tool call → 'completed'
281
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
282
+ expect.objectContaining({
283
+ update: expect.objectContaining({
284
+ sessionUpdate: 'tool_call',
285
+ toolCallId: 'call-1',
286
+ status: 'completed',
287
+ title: 'Test Tool',
288
+ kind: 'read',
289
+ content: [
290
+ {
291
+ type: 'content',
292
+ content: { type: 'text', text: 'Tool output' },
293
+ },
294
+ ],
295
+ }),
296
+ }),
297
+ );
298
+
299
+ // Failed tool call → 'failed'
300
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
301
+ expect.objectContaining({
302
+ update: expect.objectContaining({
303
+ sessionUpdate: 'tool_call',
304
+ toolCallId: 'call-2',
305
+ status: 'failed',
306
+ title: 'Write File',
307
+ kind: 'read',
308
+ }),
309
+ }),
310
+ );
311
+ });
312
+ });
313
+ });
packages/cli/src/acp/acpRpcDispatcher.test.ts ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ describe,
9
+ it,
10
+ expect,
11
+ vi,
12
+ beforeEach,
13
+ type Mock,
14
+ type Mocked,
15
+ } from 'vitest';
16
+ import { GeminiAgent } from './acpRpcDispatcher.js';
17
+ import * as acp from '@agentclientprotocol/sdk';
18
+ import {
19
+ AuthType,
20
+ type Config,
21
+ type MessageBus,
22
+ type Storage,
23
+ } from '@google/gemini-cli-core';
24
+ import type { LoadedSettings } from '../config/settings.js';
25
+ import { loadCliConfig, type CliArgs } from '../config/config.js';
26
+ import { loadSettings, SettingScope } from '../config/settings.js';
27
+
28
+ vi.mock('../config/config.js', () => ({
29
+ loadCliConfig: vi.fn(),
30
+ }));
31
+
32
+ vi.mock('../config/settings.js', async (importOriginal) => {
33
+ const actual = await importOriginal<typeof import('../config/settings.js')>();
34
+ return {
35
+ ...actual,
36
+ loadSettings: vi.fn(),
37
+ };
38
+ });
39
+
40
+ describe('GeminiAgent - RPC Dispatcher', () => {
41
+ let mockConfig: Mocked<Config>;
42
+ let mockSettings: Mocked<LoadedSettings>;
43
+ let mockArgv: CliArgs;
44
+ let mockConnection: Mocked<acp.AgentSideConnection>;
45
+ let agent: GeminiAgent;
46
+
47
+ beforeEach(() => {
48
+ mockConfig = {
49
+ refreshAuth: vi.fn(),
50
+ initialize: vi.fn(),
51
+ waitForMcpInit: vi.fn(),
52
+ getFileSystemService: vi.fn(),
53
+ setFileSystemService: vi.fn(),
54
+ getContentGeneratorConfig: vi.fn(),
55
+ getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
56
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
57
+ getGeminiClient: vi.fn().mockReturnValue({
58
+ startChat: vi.fn().mockResolvedValue({}),
59
+ }),
60
+ getMessageBus: vi.fn().mockReturnValue({
61
+ publish: vi.fn(),
62
+ subscribe: vi.fn(),
63
+ unsubscribe: vi.fn(),
64
+ }),
65
+ getApprovalMode: vi.fn().mockReturnValue('default'),
66
+ isPlanEnabled: vi.fn().mockReturnValue(true),
67
+ getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
68
+ getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
69
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
70
+ getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
71
+ validatePathAccess: vi.fn().mockReturnValue(null),
72
+ getWorkspaceContext: vi.fn().mockReturnValue({
73
+ addReadOnlyPath: vi.fn(),
74
+ getDirectories: vi.fn().mockReturnValue(['/tmp']),
75
+ }),
76
+ getPolicyEngine: vi.fn().mockReturnValue({
77
+ addRule: vi.fn(),
78
+ }),
79
+ messageBus: {
80
+ publish: vi.fn(),
81
+ subscribe: vi.fn(),
82
+ unsubscribe: vi.fn(),
83
+ } as unknown as MessageBus,
84
+ storage: {
85
+ getWorkspaceAutoSavedPolicyPath: vi.fn(),
86
+ getAutoSavedPolicyPath: vi.fn(),
87
+ } as unknown as Storage,
88
+
89
+ get config() {
90
+ return this;
91
+ },
92
+ } as unknown as Mocked<Config>;
93
+ mockSettings = {
94
+ merged: {
95
+ security: { auth: { selectedType: 'login_with_google' } },
96
+ mcpServers: {},
97
+ },
98
+ setValue: vi.fn(),
99
+ } as unknown as Mocked<LoadedSettings>;
100
+ mockArgv = {} as unknown as CliArgs;
101
+ mockConnection = {
102
+ sessionUpdate: vi.fn(),
103
+ requestPermission: vi.fn(),
104
+ } as unknown as Mocked<acp.AgentSideConnection>;
105
+
106
+ (loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
107
+ (loadSettings as unknown as Mock).mockImplementation(() => ({
108
+ merged: {
109
+ security: {
110
+ auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
111
+ enablePermanentToolApproval: true,
112
+ },
113
+ mcpServers: {},
114
+ },
115
+ setValue: vi.fn(),
116
+ }));
117
+
118
+ agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
119
+ });
120
+
121
+ it('should initialize correctly', async () => {
122
+ const response = await agent.initialize({
123
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
124
+ protocolVersion: 1,
125
+ });
126
+
127
+ expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
128
+ expect(response.authMethods).toHaveLength(4);
129
+ const gatewayAuth = response.authMethods?.find(
130
+ (m) => m.id === AuthType.GATEWAY,
131
+ );
132
+ expect(gatewayAuth?._meta).toEqual({
133
+ gateway: {
134
+ protocol: 'google',
135
+ restartRequired: 'false',
136
+ },
137
+ });
138
+ const geminiAuth = response.authMethods?.find(
139
+ (m) => m.id === AuthType.USE_GEMINI,
140
+ );
141
+ expect(geminiAuth?._meta).toEqual({
142
+ 'api-key': {
143
+ provider: 'google',
144
+ },
145
+ });
146
+ expect(response.agentCapabilities?.loadSession).toBe(true);
147
+ });
148
+
149
+ it('should authenticate correctly', async () => {
150
+ await agent.authenticate({
151
+ methodId: AuthType.LOGIN_WITH_GOOGLE,
152
+ });
153
+
154
+ expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
155
+ AuthType.LOGIN_WITH_GOOGLE,
156
+ undefined,
157
+ undefined,
158
+ undefined,
159
+ );
160
+ expect(mockSettings.setValue).toHaveBeenCalledWith(
161
+ SettingScope.User,
162
+ 'security.auth.selectedType',
163
+ AuthType.LOGIN_WITH_GOOGLE,
164
+ );
165
+ });
166
+
167
+ it('should authenticate correctly with api-key in _meta', async () => {
168
+ await agent.authenticate({
169
+ methodId: AuthType.USE_GEMINI,
170
+ _meta: {
171
+ 'api-key': 'test-api-key',
172
+ },
173
+ } as unknown as acp.AuthenticateRequest);
174
+
175
+ expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
176
+ AuthType.USE_GEMINI,
177
+ 'test-api-key',
178
+ undefined,
179
+ undefined,
180
+ );
181
+ expect(mockSettings.setValue).toHaveBeenCalledWith(
182
+ SettingScope.User,
183
+ 'security.auth.selectedType',
184
+ AuthType.USE_GEMINI,
185
+ );
186
+ });
187
+
188
+ it('should authenticate correctly with gateway method', async () => {
189
+ await agent.authenticate({
190
+ methodId: AuthType.GATEWAY,
191
+ _meta: {
192
+ gateway: {
193
+ baseUrl: 'https://example.com',
194
+ headers: { Authorization: 'Bearer token' },
195
+ },
196
+ },
197
+ } as unknown as acp.AuthenticateRequest);
198
+
199
+ expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
200
+ AuthType.GATEWAY,
201
+ undefined,
202
+ 'https://example.com',
203
+ { Authorization: 'Bearer token' },
204
+ );
205
+ expect(mockSettings.setValue).toHaveBeenCalledWith(
206
+ SettingScope.User,
207
+ 'security.auth.selectedType',
208
+ AuthType.GATEWAY,
209
+ );
210
+ });
211
+
212
+ it('should throw acp.RequestError when gateway payload is malformed', async () => {
213
+ await expect(
214
+ agent.authenticate({
215
+ methodId: AuthType.GATEWAY,
216
+ _meta: {
217
+ gateway: {
218
+ baseUrl: 123,
219
+ headers: { Authorization: 'Bearer token' },
220
+ },
221
+ },
222
+ } as unknown as acp.AuthenticateRequest),
223
+ ).rejects.toThrow(/Malformed gateway payload/);
224
+ });
225
+
226
+ it('should cancel a session', async () => {
227
+ const mockSession = {
228
+ cancelPendingPrompt: vi.fn(),
229
+ };
230
+ (
231
+ agent as unknown as { sessionManager: { getSession: Mock } }
232
+ ).sessionManager = {
233
+ getSession: vi.fn().mockReturnValue(mockSession),
234
+ };
235
+
236
+ await agent.cancel({ sessionId: 'test-session-id' });
237
+
238
+ expect(mockSession.cancelPendingPrompt).toHaveBeenCalled();
239
+ });
240
+
241
+ it('should throw error when cancelling non-existent session', async () => {
242
+ (
243
+ agent as unknown as { sessionManager: { getSession: Mock } }
244
+ ).sessionManager = {
245
+ getSession: vi.fn().mockReturnValue(undefined),
246
+ };
247
+
248
+ await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow(
249
+ 'Session not found',
250
+ );
251
+ });
252
+
253
+ it('should delegate prompt to session', async () => {
254
+ const mockSession = {
255
+ prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
256
+ };
257
+ (
258
+ agent as unknown as { sessionManager: { getSession: Mock } }
259
+ ).sessionManager = {
260
+ getSession: vi.fn().mockReturnValue(mockSession),
261
+ };
262
+
263
+ const result = await agent.prompt({
264
+ sessionId: 'test-session-id',
265
+ prompt: [],
266
+ });
267
+
268
+ expect(mockSession.prompt).toHaveBeenCalled();
269
+ expect(result).toMatchObject({ stopReason: 'end_turn' });
270
+ });
271
+
272
+ it('should delegate setMode to session', async () => {
273
+ const mockSession = {
274
+ setMode: vi.fn().mockReturnValue({}),
275
+ };
276
+ (
277
+ agent as unknown as { sessionManager: { getSession: Mock } }
278
+ ).sessionManager = {
279
+ getSession: vi.fn().mockReturnValue(mockSession),
280
+ };
281
+
282
+ const result = await agent.setSessionMode({
283
+ sessionId: 'test-session-id',
284
+ modeId: 'plan',
285
+ });
286
+
287
+ expect(mockSession.setMode).toHaveBeenCalledWith('plan');
288
+ expect(result).toEqual({});
289
+ });
290
+
291
+ it('should throw error when setting mode on non-existent session', async () => {
292
+ (
293
+ agent as unknown as { sessionManager: { getSession: Mock } }
294
+ ).sessionManager = {
295
+ getSession: vi.fn().mockReturnValue(undefined),
296
+ };
297
+
298
+ await expect(
299
+ agent.setSessionMode({
300
+ sessionId: 'unknown',
301
+ modeId: 'plan',
302
+ }),
303
+ ).rejects.toThrow('Session not found: unknown');
304
+ });
305
+
306
+ it('should delegate setModel to session (unstable)', async () => {
307
+ const mockSession = {
308
+ setModel: vi.fn().mockReturnValue({}),
309
+ };
310
+ (
311
+ agent as unknown as { sessionManager: { getSession: Mock } }
312
+ ).sessionManager = {
313
+ getSession: vi.fn().mockReturnValue(mockSession),
314
+ };
315
+
316
+ const result = await agent.unstable_setSessionModel({
317
+ sessionId: 'test-session-id',
318
+ modelId: 'gemini-2.0-pro-exp',
319
+ });
320
+
321
+ expect(mockSession.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
322
+ expect(result).toEqual({});
323
+ });
324
+
325
+ it('should throw error when setting model on non-existent session (unstable)', async () => {
326
+ (
327
+ agent as unknown as { sessionManager: { getSession: Mock } }
328
+ ).sessionManager = {
329
+ getSession: vi.fn().mockReturnValue(undefined),
330
+ };
331
+
332
+ await expect(
333
+ agent.unstable_setSessionModel({
334
+ sessionId: 'unknown',
335
+ modelId: 'gemini-2.0-pro-exp',
336
+ }),
337
+ ).rejects.toThrow('Session not found: unknown');
338
+ });
339
+ });
packages/cli/src/acp/acpRpcDispatcher.ts ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ type AgentLoopContext,
9
+ AuthType,
10
+ clearCachedCredentialFile,
11
+ getVersion,
12
+ } from '@google/gemini-cli-core';
13
+ import * as acp from '@agentclientprotocol/sdk';
14
+ import { z } from 'zod';
15
+ import { SettingScope, type LoadedSettings } from '../config/settings.js';
16
+ import type { CliArgs } from '../config/config.js';
17
+ import { getAcpErrorMessage } from './acpErrors.js';
18
+ import { AcpSessionManager, type AuthDetails } from './acpSessionManager.js';
19
+ import { hasMeta } from './acpUtils.js';
20
+
21
+ export class GeminiAgent {
22
+ private apiKey: string | undefined;
23
+ private baseUrl: string | undefined;
24
+ private customHeaders: Record<string, string> | undefined;
25
+ private sessionManager: AcpSessionManager;
26
+
27
+ constructor(
28
+ private context: AgentLoopContext,
29
+ private settings: LoadedSettings,
30
+ argv: CliArgs,
31
+ connection: acp.AgentSideConnection,
32
+ ) {
33
+ this.sessionManager = new AcpSessionManager(settings, argv, connection);
34
+ }
35
+
36
+ dispose(): void {
37
+ this.sessionManager.dispose();
38
+ }
39
+
40
+ async initialize(
41
+ args: acp.InitializeRequest,
42
+ ): Promise<acp.InitializeResponse> {
43
+ if (args.clientCapabilities) {
44
+ this.sessionManager.setClientCapabilities(args.clientCapabilities);
45
+ }
46
+
47
+ const authMethods = [
48
+ {
49
+ id: AuthType.LOGIN_WITH_GOOGLE,
50
+ name: 'Log in with Google',
51
+ description: 'Log in with your Google account',
52
+ },
53
+ {
54
+ id: AuthType.USE_GEMINI,
55
+ name: 'Gemini API key',
56
+ description: 'Use an API key with Gemini Developer API',
57
+ _meta: {
58
+ 'api-key': {
59
+ provider: 'google',
60
+ },
61
+ },
62
+ },
63
+ {
64
+ id: AuthType.USE_VERTEX_AI,
65
+ name: 'Vertex AI',
66
+ description: 'Use an API key with Vertex AI GenAI API',
67
+ },
68
+ {
69
+ id: AuthType.GATEWAY,
70
+ name: 'AI API Gateway',
71
+ description: 'Use a custom AI API Gateway',
72
+ _meta: {
73
+ gateway: {
74
+ protocol: 'google',
75
+ restartRequired: 'false',
76
+ },
77
+ },
78
+ },
79
+ ];
80
+
81
+ await this.context.config.initialize();
82
+ const version = await getVersion();
83
+ return {
84
+ protocolVersion: acp.PROTOCOL_VERSION,
85
+ authMethods,
86
+ agentInfo: {
87
+ name: 'gemini-cli',
88
+ title: 'Gemini CLI',
89
+ version,
90
+ },
91
+ agentCapabilities: {
92
+ loadSession: true,
93
+ promptCapabilities: {
94
+ image: true,
95
+ audio: true,
96
+ embeddedContext: true,
97
+ },
98
+ mcpCapabilities: {
99
+ http: true,
100
+ sse: true,
101
+ },
102
+ },
103
+ };
104
+ }
105
+
106
+ async authenticate(req: acp.AuthenticateRequest): Promise<void> {
107
+ const { methodId } = req;
108
+ const method = z.nativeEnum(AuthType).parse(methodId);
109
+ const selectedAuthType = this.settings.merged.security.auth.selectedType;
110
+
111
+ // Only clear credentials when switching to a different auth method
112
+ if (selectedAuthType && selectedAuthType !== method) {
113
+ await clearCachedCredentialFile();
114
+ }
115
+ // Check for api-key in _meta
116
+ const meta = hasMeta(req) ? req._meta : undefined;
117
+ const apiKey =
118
+ typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
119
+
120
+ // Refresh auth with the requested method
121
+ // This will reuse existing credentials if they're valid,
122
+ // or perform new authentication if needed
123
+ try {
124
+ if (apiKey) {
125
+ this.apiKey = apiKey;
126
+ }
127
+
128
+ // Extract gateway details if present
129
+ const gatewaySchema = z.object({
130
+ baseUrl: z.string().optional(),
131
+ headers: z.record(z.string()).optional(),
132
+ });
133
+
134
+ let baseUrl: string | undefined;
135
+ let headers: Record<string, string> | undefined;
136
+
137
+ if (meta?.['gateway']) {
138
+ const result = gatewaySchema.safeParse(meta['gateway']);
139
+ if (result.success) {
140
+ baseUrl = result.data.baseUrl;
141
+ headers = result.data.headers;
142
+ } else {
143
+ throw new acp.RequestError(
144
+ -32602,
145
+ `Malformed gateway payload: ${result.error.message}`,
146
+ );
147
+ }
148
+ }
149
+
150
+ this.baseUrl = baseUrl;
151
+ this.customHeaders = headers;
152
+
153
+ await this.context.config.refreshAuth(
154
+ method,
155
+ apiKey ?? this.apiKey,
156
+ baseUrl,
157
+ headers,
158
+ );
159
+ } catch (e) {
160
+ throw new acp.RequestError(-32000, getAcpErrorMessage(e));
161
+ }
162
+ this.settings.setValue(
163
+ SettingScope.User,
164
+ 'security.auth.selectedType',
165
+ method,
166
+ );
167
+ }
168
+
169
+ private getAuthDetails(): AuthDetails {
170
+ return {
171
+ apiKey: this.apiKey,
172
+ baseUrl: this.baseUrl,
173
+ customHeaders: this.customHeaders,
174
+ };
175
+ }
176
+
177
+ async newSession(
178
+ params: acp.NewSessionRequest,
179
+ ): Promise<acp.NewSessionResponse> {
180
+ return this.sessionManager.newSession(params, this.getAuthDetails());
181
+ }
182
+
183
+ async loadSession(
184
+ params: acp.LoadSessionRequest,
185
+ ): Promise<acp.LoadSessionResponse> {
186
+ return this.sessionManager.loadSession(params, this.getAuthDetails());
187
+ }
188
+
189
+ async cancel(params: acp.CancelNotification): Promise<void> {
190
+ const session = this.sessionManager.getSession(params.sessionId);
191
+ if (!session) {
192
+ throw new acp.RequestError(
193
+ -32602,
194
+ `Session not found: ${params.sessionId}`,
195
+ );
196
+ }
197
+ await session.cancelPendingPrompt();
198
+ }
199
+
200
+ async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
201
+ const session = this.sessionManager.getSession(params.sessionId);
202
+ if (!session) {
203
+ throw new acp.RequestError(
204
+ -32602,
205
+ `Session not found: ${params.sessionId}`,
206
+ );
207
+ }
208
+ return session.prompt(params);
209
+ }
210
+
211
+ async setSessionMode(
212
+ params: acp.SetSessionModeRequest,
213
+ ): Promise<acp.SetSessionModeResponse> {
214
+ const session = this.sessionManager.getSession(params.sessionId);
215
+ if (!session) {
216
+ throw new acp.RequestError(
217
+ -32602,
218
+ `Session not found: ${params.sessionId}`,
219
+ );
220
+ }
221
+ return session.setMode(params.modeId);
222
+ }
223
+
224
+ async unstable_setSessionModel(
225
+ params: acp.SetSessionModelRequest,
226
+ ): Promise<acp.SetSessionModelResponse> {
227
+ const session = this.sessionManager.getSession(params.sessionId);
228
+ if (!session) {
229
+ throw new acp.RequestError(
230
+ -32602,
231
+ `Session not found: ${params.sessionId}`,
232
+ );
233
+ }
234
+ return session.setModel(params.modelId);
235
+ }
236
+ }
packages/cli/src/acp/acpSession.test.ts ADDED
@@ -0,0 +1,1144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ describe,
9
+ it,
10
+ expect,
11
+ vi,
12
+ beforeEach,
13
+ afterEach,
14
+ type Mock,
15
+ type Mocked,
16
+ type MockInstance,
17
+ } from 'vitest';
18
+ import { Session } from './acpSession.js';
19
+ import type * as acp from '@agentclientprotocol/sdk';
20
+ import {
21
+ ReadManyFilesTool,
22
+ type GeminiChat,
23
+ type Config,
24
+ type MessageBus,
25
+ type GitService,
26
+ InvalidStreamError,
27
+ GeminiEventType,
28
+ type ServerGeminiStreamEvent,
29
+ PolicyDecision,
30
+ MessageBusType,
31
+ type ToolConfirmationRequest,
32
+ DiscoveredMCPTool,
33
+ } from '@google/gemini-cli-core';
34
+ import type { LoadedSettings } from '../config/settings.js';
35
+ import { type Part, FinishReason } from '@google/genai';
36
+ import * as fs from 'node:fs/promises';
37
+ import * as path from 'node:path';
38
+ import type { CommandHandler } from './acpCommandHandler.js';
39
+
40
+ vi.mock('node:fs/promises');
41
+ vi.mock('node:path', async (importOriginal) => {
42
+ const actual = await importOriginal<typeof import('node:path')>();
43
+ return {
44
+ ...actual,
45
+ resolve: vi.fn(),
46
+ };
47
+ });
48
+
49
+ vi.mock(
50
+ '@google/gemini-cli-core',
51
+ async (
52
+ importOriginal: () => Promise<typeof import('@google/gemini-cli-core')>,
53
+ ) => {
54
+ const actual = await importOriginal();
55
+ return {
56
+ ...actual,
57
+ updatePolicy: vi.fn(),
58
+ ReadManyFilesTool: vi.fn(),
59
+ logToolCall: vi.fn(),
60
+ processSingleFileContent: vi.fn(),
61
+ };
62
+ },
63
+ );
64
+
65
+ async function* createMockStream(
66
+ items: readonly ServerGeminiStreamEvent[],
67
+ ): AsyncGenerator<ServerGeminiStreamEvent> {
68
+ for (const item of items) {
69
+ yield item;
70
+ }
71
+
72
+ yield {
73
+ type: GeminiEventType.Finished,
74
+ value: {
75
+ reason: FinishReason.STOP,
76
+ usageMetadata: {
77
+ promptTokenCount: 5,
78
+ candidatesTokenCount: 10,
79
+ },
80
+ },
81
+ };
82
+ }
83
+
84
+ describe('Session', () => {
85
+ let mockChat: Mocked<GeminiChat>;
86
+ let mockConfig: Mocked<Config>;
87
+ let mockConnection: Mocked<acp.AgentSideConnection>;
88
+ let session: Session;
89
+ let mockToolRegistry: { getTool: Mock };
90
+ let mockTool: { kind: string; build: Mock };
91
+ let mockMessageBus: Mocked<MessageBus>;
92
+ let mockSendMessageStream: MockInstance<
93
+ (
94
+ request: Part[],
95
+ signal: AbortSignal,
96
+ promptId: string,
97
+ ) => AsyncGenerator<ServerGeminiStreamEvent>
98
+ >;
99
+
100
+ beforeEach(() => {
101
+ mockChat = {
102
+ sendMessageStream: vi.fn(),
103
+ addHistory: vi.fn(),
104
+ recordCompletedToolCalls: vi.fn(),
105
+ getHistory: vi.fn().mockReturnValue([]),
106
+ } as unknown as Mocked<GeminiChat>;
107
+ mockTool = {
108
+ kind: 'read',
109
+ build: vi.fn().mockReturnValue({
110
+ getDescription: () => 'Test Tool',
111
+ toolLocations: () => [],
112
+ shouldConfirmExecute: vi.fn().mockResolvedValue(null),
113
+ execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
114
+ }),
115
+ };
116
+ mockToolRegistry = {
117
+ getTool: vi.fn().mockReturnValue(mockTool),
118
+ };
119
+ mockMessageBus = {
120
+ publish: vi.fn(),
121
+ subscribe: vi.fn(),
122
+ unsubscribe: vi.fn(),
123
+ } as unknown as Mocked<MessageBus>;
124
+ mockSendMessageStream = vi.fn();
125
+ mockConfig = {
126
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
127
+ getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
128
+ getModelRouterService: vi.fn().mockReturnValue({
129
+ route: vi.fn().mockResolvedValue({ model: 'resolved-model' }),
130
+ }),
131
+ getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
132
+ getFileService: vi.fn().mockReturnValue({
133
+ shouldIgnoreFile: vi.fn().mockReturnValue(false),
134
+ }),
135
+ getFileFilteringOptions: vi.fn().mockReturnValue({}),
136
+ getFileSystemService: vi.fn().mockReturnValue({}),
137
+ getTargetDir: vi.fn().mockReturnValue('/tmp'),
138
+ getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
139
+ getDebugMode: vi.fn().mockReturnValue(false),
140
+ getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
141
+ setApprovalMode: vi.fn(),
142
+ setModel: vi.fn(),
143
+ isPlanEnabled: vi.fn().mockReturnValue(true),
144
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
145
+ getGitService: vi.fn().mockResolvedValue({} as GitService),
146
+ getPolicyEngine: vi.fn().mockReturnValue({
147
+ check: vi.fn(),
148
+ }),
149
+ validatePathAccess: vi.fn().mockReturnValue(null),
150
+ getWorkspaceContext: vi.fn().mockReturnValue({
151
+ addReadOnlyPath: vi.fn(),
152
+ getDirectories: vi.fn().mockReturnValue(['/tmp']),
153
+ }),
154
+ waitForMcpInit: vi.fn(),
155
+ getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
156
+ getMaxSessionTurns: vi.fn().mockReturnValue(-1),
157
+ geminiClient: {
158
+ sendMessageStream: mockSendMessageStream,
159
+ getChat: vi.fn().mockReturnValue(mockChat),
160
+ },
161
+ get config() {
162
+ return this;
163
+ },
164
+ get toolRegistry() {
165
+ return mockToolRegistry;
166
+ },
167
+ } as unknown as Mocked<Config>;
168
+ mockConnection = {
169
+ sessionUpdate: vi.fn(),
170
+ requestPermission: vi.fn(),
171
+ } as unknown as Mocked<acp.AgentSideConnection>;
172
+
173
+ session = new Session('session-1', mockChat, mockConfig, mockConnection, {
174
+ merged: {
175
+ security: { enablePermanentToolApproval: true },
176
+ mcpServers: {},
177
+ },
178
+ errors: [],
179
+ } as unknown as LoadedSettings);
180
+
181
+ (ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({
182
+ name: 'read_many_files',
183
+ kind: 'read',
184
+ build: vi.fn().mockReturnValue({
185
+ getDescription: () => 'Read files',
186
+ toolLocations: () => [],
187
+ execute: vi.fn().mockResolvedValue({
188
+ llmContent: ['--- file.txt ---\n\nFile content\n\n'],
189
+ }),
190
+ }),
191
+ }));
192
+ });
193
+
194
+ afterEach(() => {
195
+ vi.restoreAllMocks();
196
+ });
197
+
198
+ it('should send available commands', async () => {
199
+ await session.sendAvailableCommands();
200
+
201
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
202
+ expect.objectContaining({
203
+ update: expect.objectContaining({
204
+ sessionUpdate: 'available_commands_update',
205
+ }),
206
+ }),
207
+ );
208
+ });
209
+
210
+ it('should await MCP initialization before processing a prompt', async () => {
211
+ const stream = createMockStream([
212
+ {
213
+ type: GeminiEventType.Content,
214
+ value: 'Hi',
215
+ },
216
+ ]);
217
+ mockSendMessageStream.mockReturnValue(stream);
218
+
219
+ await session.prompt({
220
+ sessionId: 'session-1',
221
+ prompt: [{ type: 'text', text: 'test' }],
222
+ });
223
+
224
+ expect(mockConfig.waitForMcpInit).toHaveBeenCalledOnce();
225
+ });
226
+
227
+ it('should handle prompt with text response', async () => {
228
+ const stream = createMockStream([
229
+ {
230
+ type: GeminiEventType.Content,
231
+ value: 'Hello',
232
+ },
233
+ ]);
234
+ mockSendMessageStream.mockReturnValue(stream);
235
+
236
+ const result = await session.prompt({
237
+ sessionId: 'session-1',
238
+ prompt: [{ type: 'text', text: 'Hi' }],
239
+ });
240
+
241
+ expect(mockSendMessageStream).toHaveBeenCalled();
242
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({
243
+ sessionId: 'session-1',
244
+ update: {
245
+ sessionUpdate: 'agent_message_chunk',
246
+ content: { type: 'text', text: 'Hello' },
247
+ },
248
+ });
249
+ expect(result).toMatchObject({ stopReason: 'end_turn' });
250
+ });
251
+
252
+ it('should pass current session information directly onto geminiClient.sendMessageStream', async () => {
253
+ const stream = createMockStream([
254
+ {
255
+ type: GeminiEventType.Content,
256
+ value: 'Hello',
257
+ },
258
+ ]);
259
+ mockSendMessageStream.mockReturnValue(stream);
260
+
261
+ await session.prompt({
262
+ sessionId: 'session-1',
263
+ prompt: [{ type: 'text', text: 'Hi' }],
264
+ });
265
+
266
+ expect(mockSendMessageStream).toHaveBeenCalledWith(
267
+ expect.arrayContaining([{ text: 'Hi' }]),
268
+ expect.any(AbortSignal),
269
+ expect.any(String),
270
+ );
271
+ });
272
+
273
+ it('should handle prompt with empty response (InvalidStreamError)', async () => {
274
+ const error = new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT');
275
+ mockSendMessageStream.mockImplementation(() => {
276
+ async function* errorGen(): AsyncGenerator<
277
+ ServerGeminiStreamEvent,
278
+ void,
279
+ unknown
280
+ > {
281
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
282
+ yield* [] as any;
283
+ throw error;
284
+ }
285
+ return errorGen();
286
+ });
287
+
288
+ const result = await session.prompt({
289
+ sessionId: 'session-1',
290
+ prompt: [{ type: 'text', text: 'Hi' }],
291
+ });
292
+
293
+ expect(result).toMatchObject({ stopReason: 'end_turn' });
294
+ });
295
+
296
+ it('should handle prompt with no finish reason (InvalidStreamError)', async () => {
297
+ const error = new InvalidStreamError(
298
+ 'No finish reason',
299
+ 'NO_FINISH_REASON',
300
+ );
301
+ mockSendMessageStream.mockImplementation(() => {
302
+ async function* errorGen(): AsyncGenerator<
303
+ ServerGeminiStreamEvent,
304
+ void,
305
+ unknown
306
+ > {
307
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
308
+ yield* [] as any;
309
+ throw error;
310
+ }
311
+ return errorGen();
312
+ });
313
+
314
+ const result = await session.prompt({
315
+ sessionId: 'session-1',
316
+ prompt: [{ type: 'text', text: 'Hi' }],
317
+ });
318
+
319
+ expect(result).toMatchObject({ stopReason: 'end_turn' });
320
+ });
321
+
322
+ it.each([
323
+ { type: 'MAX_TOKENS_EXCEEDED', reason: 'MAX_TOKENS' },
324
+ { type: 'SAFETY_BLOCKED', reason: 'SAFETY' },
325
+ { type: 'RECITATION_BLOCKED', reason: 'RECITATION' },
326
+ { type: 'OTHER_BLOCKED', reason: 'OTHER' },
327
+ { type: 'THINKING_ONLY_RESPONSE', reason: 'STOP' },
328
+ ])(
329
+ 'should gracefully handle InvalidStreamError with type $type in ACP session',
330
+ async ({ type, reason }) => {
331
+ const error = new InvalidStreamError(
332
+ `Stream failed with ${reason}`,
333
+ type as InvalidStreamError['type'],
334
+ );
335
+ mockSendMessageStream.mockImplementation(() => {
336
+ async function* errorGen(): AsyncGenerator<
337
+ ServerGeminiStreamEvent,
338
+ void,
339
+ unknown
340
+ > {
341
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
342
+ yield* [] as any;
343
+ throw error;
344
+ }
345
+ return errorGen();
346
+ });
347
+
348
+ const result = await session.prompt({
349
+ sessionId: 'session-1',
350
+ prompt: [{ type: 'text', text: 'Hi' }],
351
+ });
352
+
353
+ expect(result).toMatchObject({ stopReason: 'end_turn' });
354
+ },
355
+ );
356
+
357
+ it('should handle /memory command', async () => {
358
+ const handleCommandSpy = vi
359
+ .spyOn(
360
+ (session as unknown as { commandHandler: CommandHandler })
361
+ .commandHandler,
362
+ 'handleCommand',
363
+ )
364
+ .mockResolvedValue(true);
365
+
366
+ const result = await session.prompt({
367
+ sessionId: 'session-1',
368
+ prompt: [{ type: 'text', text: '/memory view' }],
369
+ });
370
+
371
+ expect(result).toMatchObject({ stopReason: 'end_turn' });
372
+ expect(handleCommandSpy).toHaveBeenCalledWith(
373
+ '/memory view',
374
+ expect.any(Object),
375
+ );
376
+ });
377
+
378
+ it('should handle tool calls', async () => {
379
+ const stream1 = createMockStream([
380
+ {
381
+ type: GeminiEventType.ToolCallRequest,
382
+ value: {
383
+ callId: 'call-1',
384
+ name: 'test_tool',
385
+ args: { foo: 'bar' },
386
+ isClientInitiated: false,
387
+ prompt_id: 'prompt-1',
388
+ },
389
+ },
390
+ ]);
391
+ const stream2 = createMockStream([
392
+ {
393
+ type: GeminiEventType.Content,
394
+ value: 'Result',
395
+ },
396
+ ]);
397
+
398
+ mockSendMessageStream
399
+ .mockReturnValueOnce(stream1)
400
+ .mockReturnValueOnce(stream2);
401
+
402
+ const result = await session.prompt({
403
+ sessionId: 'session-1',
404
+ prompt: [{ type: 'text', text: 'Call tool' }],
405
+ });
406
+
407
+ expect(mockToolRegistry.getTool).toHaveBeenCalledWith('test_tool');
408
+ expect(result).toMatchObject({ stopReason: 'end_turn' });
409
+ });
410
+
411
+ it('should handle tool call permission request', async () => {
412
+ const confirmationDetails = {
413
+ type: 'info',
414
+ onConfirm: vi.fn(),
415
+ };
416
+ mockTool.build.mockReturnValue({
417
+ getDescription: () => 'Test Tool',
418
+ toolLocations: () => [],
419
+ shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
420
+ execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
421
+ });
422
+
423
+ mockConnection.requestPermission.mockResolvedValue({
424
+ outcome: {
425
+ outcome: 'selected',
426
+ optionId: 'proceed_once',
427
+ },
428
+ });
429
+
430
+ const stream1 = createMockStream([
431
+ {
432
+ type: GeminiEventType.ToolCallRequest,
433
+ value: {
434
+ callId: 'call-1',
435
+ name: 'test_tool',
436
+ args: {},
437
+ isClientInitiated: false,
438
+ prompt_id: 'prompt-1',
439
+ },
440
+ },
441
+ ]);
442
+ const stream2 = createMockStream([
443
+ {
444
+ type: GeminiEventType.Content,
445
+ value: '',
446
+ },
447
+ ]);
448
+
449
+ mockSendMessageStream
450
+ .mockReturnValueOnce(stream1)
451
+ .mockReturnValueOnce(stream2);
452
+
453
+ await session.prompt({
454
+ sessionId: 'session-1',
455
+ prompt: [{ type: 'text', text: 'Call tool' }],
456
+ });
457
+
458
+ expect(mockConnection.requestPermission).toHaveBeenCalled();
459
+ expect(confirmationDetails.onConfirm).toHaveBeenCalled();
460
+ });
461
+
462
+ it('should handle @path resolution', async () => {
463
+ (path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt');
464
+ (fs.stat as unknown as Mock).mockResolvedValue({
465
+ isDirectory: () => false,
466
+ });
467
+
468
+ const stream = createMockStream([
469
+ {
470
+ type: GeminiEventType.Content,
471
+ value: '',
472
+ },
473
+ ]);
474
+ mockSendMessageStream.mockReturnValue(stream);
475
+
476
+ await session.prompt({
477
+ sessionId: 'session-1',
478
+ prompt: [
479
+ { type: 'text', text: 'Read' },
480
+ {
481
+ type: 'resource_link',
482
+ uri: 'file://file.txt',
483
+ mimeType: 'text/plain',
484
+ name: 'file.txt',
485
+ },
486
+ ],
487
+ });
488
+
489
+ expect(path.resolve).toHaveBeenCalled();
490
+ expect(fs.stat).toHaveBeenCalled();
491
+ expect(mockSendMessageStream).toHaveBeenCalledWith(
492
+ expect.arrayContaining([
493
+ expect.objectContaining({
494
+ text: expect.stringContaining('Content from @file.txt'),
495
+ }),
496
+ ]),
497
+ expect.any(AbortSignal),
498
+ expect.any(String),
499
+ );
500
+ });
501
+
502
+ it('should handle rate limit error', async () => {
503
+ const error = new Error('Rate limit');
504
+ const customError = error as { status?: number; message?: string };
505
+ customError.status = 429;
506
+
507
+ mockSendMessageStream.mockImplementation(() => {
508
+ async function* errorGen(): AsyncGenerator<
509
+ ServerGeminiStreamEvent,
510
+ void,
511
+ unknown
512
+ > {
513
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
514
+ yield* [] as any;
515
+ throw customError;
516
+ }
517
+ return errorGen();
518
+ });
519
+
520
+ await expect(
521
+ session.prompt({
522
+ sessionId: 'session-1',
523
+ prompt: [{ type: 'text', text: 'Hi' }],
524
+ }),
525
+ ).rejects.toMatchObject({
526
+ code: 429,
527
+ message: 'Rate limit exceeded. Try again later.',
528
+ });
529
+ });
530
+
531
+ it('should handle missing tool', async () => {
532
+ mockToolRegistry.getTool.mockReturnValue(undefined);
533
+
534
+ const stream1 = createMockStream([
535
+ {
536
+ type: GeminiEventType.ToolCallRequest,
537
+ value: {
538
+ callId: 'call-1',
539
+ name: 'unknown_tool',
540
+ args: {},
541
+ isClientInitiated: false,
542
+ prompt_id: 'prompt-1',
543
+ },
544
+ },
545
+ ]);
546
+ const stream2 = createMockStream([
547
+ {
548
+ type: GeminiEventType.Content,
549
+ value: '',
550
+ },
551
+ ]);
552
+
553
+ mockSendMessageStream
554
+ .mockReturnValueOnce(stream1)
555
+ .mockReturnValueOnce(stream2);
556
+
557
+ await session.prompt({
558
+ sessionId: 'session-1',
559
+ prompt: [{ type: 'text', text: 'Call tool' }],
560
+ });
561
+
562
+ expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
563
+ });
564
+
565
+ it('should handle GeminiEventType.LoopDetected', async () => {
566
+ const stream = createMockStream([
567
+ {
568
+ type: GeminiEventType.LoopDetected,
569
+ },
570
+ ]);
571
+ mockSendMessageStream.mockReturnValue(stream);
572
+
573
+ const result = await session.prompt({
574
+ sessionId: 'session-1',
575
+ prompt: [{ type: 'text', text: 'Trigger Loop Simulation' }],
576
+ });
577
+
578
+ expect(result.stopReason).toBe('max_turn_requests');
579
+ });
580
+
581
+ it('should handle GeminiEventType.ContextWindowWillOverflow', async () => {
582
+ const stream = createMockStream([
583
+ {
584
+ type: GeminiEventType.ContextWindowWillOverflow,
585
+ value: { estimatedRequestTokenCount: 1000, remainingTokenCount: 200 },
586
+ },
587
+ ]);
588
+ mockSendMessageStream.mockReturnValue(stream);
589
+
590
+ const result = await session.prompt({
591
+ sessionId: 'session-1',
592
+ prompt: [{ type: 'text', text: 'Trigger Overflow Simulation' }],
593
+ });
594
+
595
+ expect(result.stopReason).toBe('max_tokens');
596
+ });
597
+
598
+ it('should handle GeminiEventType.MaxSessionTurns', async () => {
599
+ const stream = createMockStream([
600
+ {
601
+ type: GeminiEventType.MaxSessionTurns,
602
+ },
603
+ ]);
604
+ mockSendMessageStream.mockReturnValue(stream);
605
+
606
+ const result = await session.prompt({
607
+ sessionId: 'session-1',
608
+ prompt: [{ type: 'text', text: 'Trigger Safety Limits' }],
609
+ });
610
+
611
+ expect(result.stopReason).toBe('max_turn_requests');
612
+ });
613
+
614
+ it('should send sessionUpdate when approval mode changes', async () => {
615
+ const { coreEvents, CoreEvent, ApprovalMode } = await import(
616
+ '@google/gemini-cli-core'
617
+ );
618
+
619
+ coreEvents.emit(CoreEvent.ApprovalModeChanged, {
620
+ sessionId: 'session-1',
621
+ mode: ApprovalMode.PLAN,
622
+ });
623
+
624
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({
625
+ sessionId: 'session-1',
626
+ update: {
627
+ sessionUpdate: 'agent_message_chunk',
628
+ content: {
629
+ type: 'text',
630
+ text: `[MODE_UPDATE] ${ApprovalMode.PLAN}`,
631
+ },
632
+ },
633
+ });
634
+ });
635
+
636
+ it('should add explanation to tool call content instead of thought chunk', async () => {
637
+ mockTool.build.mockReturnValue({
638
+ getDescription: () => 'Test Tool',
639
+ getExplanation: () => 'Test Explanation',
640
+ toolLocations: () => [],
641
+ shouldConfirmExecute: vi
642
+ .fn()
643
+ .mockResolvedValue({ type: 'info', onConfirm: vi.fn() }),
644
+ execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
645
+ });
646
+
647
+ mockConnection.requestPermission.mockResolvedValue({
648
+ outcome: {
649
+ outcome: 'selected',
650
+ optionId: 'proceed_once',
651
+ },
652
+ });
653
+
654
+ const stream1 = createMockStream([
655
+ {
656
+ type: GeminiEventType.ToolCallRequest,
657
+ value: {
658
+ callId: 'call-1',
659
+ name: 'test_tool',
660
+ args: {},
661
+ isClientInitiated: false,
662
+ prompt_id: 'prompt-1',
663
+ },
664
+ },
665
+ ]);
666
+ const stream2 = createMockStream([
667
+ {
668
+ type: GeminiEventType.Content,
669
+ value: '',
670
+ },
671
+ ]);
672
+
673
+ mockSendMessageStream
674
+ .mockReturnValueOnce(stream1)
675
+ .mockReturnValueOnce(stream2);
676
+
677
+ await session.prompt({
678
+ sessionId: 'session-1',
679
+ prompt: [{ type: 'text', text: 'Call tool' }],
680
+ });
681
+
682
+ expect(mockConnection.sessionUpdate).not.toHaveBeenCalledWith(
683
+ expect.objectContaining({
684
+ update: expect.objectContaining({
685
+ sessionUpdate: 'agent_thought_chunk',
686
+ content: { type: 'text', text: 'Test Explanation' },
687
+ }),
688
+ }),
689
+ );
690
+
691
+ expect(mockConnection.requestPermission).toHaveBeenCalledWith(
692
+ expect.objectContaining({
693
+ toolCall: expect.objectContaining({
694
+ content: expect.arrayContaining([
695
+ {
696
+ type: 'content',
697
+ content: { type: 'text', text: 'Test Explanation' },
698
+ },
699
+ ]),
700
+ }),
701
+ }),
702
+ );
703
+ });
704
+
705
+ it('should include both diff and explanation in request_permission content for edit tools', async () => {
706
+ mockTool.build.mockReturnValue({
707
+ getDescription: () => 'edit_file(file_path: test.ts)',
708
+ getDisplayTitle: () => 'edit_file(file_path: test.ts)',
709
+ getExplanation: () => 'Updating configuration value',
710
+ toolLocations: () => [],
711
+ shouldConfirmExecute: vi.fn().mockResolvedValue({
712
+ type: 'edit',
713
+ filePath: 'test.ts',
714
+ originalContent: 'old',
715
+ newContent: 'new',
716
+ onConfirm: vi.fn(),
717
+ }),
718
+ execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
719
+ });
720
+
721
+ mockConnection.requestPermission.mockResolvedValue({
722
+ outcome: {
723
+ outcome: 'selected',
724
+ optionId: 'proceed_once',
725
+ },
726
+ });
727
+
728
+ const stream1 = createMockStream([
729
+ {
730
+ type: GeminiEventType.ToolCallRequest,
731
+ value: {
732
+ callId: 'call-edit-1',
733
+ name: 'test_tool',
734
+ args: {},
735
+ isClientInitiated: false,
736
+ prompt_id: 'prompt-1',
737
+ },
738
+ },
739
+ ]);
740
+ const stream2 = createMockStream([
741
+ {
742
+ type: GeminiEventType.Content,
743
+ value: '',
744
+ },
745
+ ]);
746
+
747
+ mockSendMessageStream
748
+ .mockReturnValueOnce(stream1)
749
+ .mockReturnValueOnce(stream2);
750
+
751
+ await session.prompt({
752
+ sessionId: 'session-1',
753
+ prompt: [{ type: 'text', text: 'Edit file' }],
754
+ });
755
+
756
+ expect(mockConnection.requestPermission).toHaveBeenCalledWith(
757
+ expect.objectContaining({
758
+ toolCall: expect.objectContaining({
759
+ title: 'edit_file(file_path: test.ts)',
760
+ content: [
761
+ expect.objectContaining({
762
+ type: 'diff',
763
+ path: 'test.ts',
764
+ }),
765
+ {
766
+ type: 'content',
767
+ content: { type: 'text', text: 'Updating configuration value' },
768
+ },
769
+ ],
770
+ }),
771
+ }),
772
+ );
773
+ });
774
+
775
+ it('should add explanation to tool_call update content instead of thought chunk when no permission required', async () => {
776
+ mockTool.build.mockReturnValue({
777
+ getDescription: () => 'Test Tool',
778
+ getExplanation: () => 'Test Explanation',
779
+ toolLocations: () => [],
780
+ shouldConfirmExecute: vi.fn().mockResolvedValue(null),
781
+ execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
782
+ });
783
+
784
+ const stream1 = createMockStream([
785
+ {
786
+ type: GeminiEventType.ToolCallRequest,
787
+ value: {
788
+ callId: 'call-1',
789
+ name: 'test_tool',
790
+ args: {},
791
+ isClientInitiated: false,
792
+ prompt_id: 'prompt-1',
793
+ },
794
+ },
795
+ ]);
796
+ const stream2 = createMockStream([
797
+ {
798
+ type: GeminiEventType.Content,
799
+ value: '',
800
+ },
801
+ ]);
802
+
803
+ mockSendMessageStream
804
+ .mockReturnValueOnce(stream1)
805
+ .mockReturnValueOnce(stream2);
806
+
807
+ await session.prompt({
808
+ sessionId: 'session-1',
809
+ prompt: [{ type: 'text', text: 'Call tool' }],
810
+ });
811
+
812
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
813
+ expect.objectContaining({
814
+ update: expect.objectContaining({
815
+ sessionUpdate: 'tool_call',
816
+ content: expect.arrayContaining([
817
+ {
818
+ type: 'content',
819
+ content: { type: 'text', text: 'Test Explanation' },
820
+ },
821
+ ]),
822
+ }),
823
+ }),
824
+ );
825
+ });
826
+
827
+ describe('Policy Handling', () => {
828
+ it('should auto-approve tool calls when PolicyEngine returns ALLOW', async () => {
829
+ const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
830
+ check: Mock<
831
+ (
832
+ toolCall: { name: string; args: Record<string, unknown> },
833
+ serverName?: string,
834
+ toolAnnotations?: Record<string, unknown>,
835
+ subagent?: string,
836
+ ) => Promise<{ decision: PolicyDecision }>
837
+ >;
838
+ };
839
+ mockPolicyEngine.check.mockResolvedValue({
840
+ decision: PolicyDecision.ALLOW,
841
+ });
842
+
843
+ // Trigger the subscription handler
844
+ const handler = mockMessageBus.subscribe.mock.calls.find(
845
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
846
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
847
+
848
+ expect(handler).toBeDefined();
849
+
850
+ await handler({
851
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
852
+ correlationId: 'test-id',
853
+ toolCall: { name: 'ls', args: {} },
854
+ });
855
+
856
+ expect(mockMessageBus.publish).toHaveBeenCalledWith(
857
+ expect.objectContaining({
858
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
859
+ correlationId: 'test-id',
860
+ confirmed: true,
861
+ requiresUserConfirmation: false,
862
+ }),
863
+ );
864
+ });
865
+
866
+ it('should request user confirmation when PolicyEngine returns ASK_USER', async () => {
867
+ const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
868
+ check: Mock<
869
+ (
870
+ toolCall: { name: string; args: Record<string, unknown> },
871
+ serverName?: string,
872
+ toolAnnotations?: Record<string, unknown>,
873
+ subagent?: string,
874
+ ) => Promise<{ decision: PolicyDecision }>
875
+ >;
876
+ };
877
+ mockPolicyEngine.check.mockResolvedValue({
878
+ decision: PolicyDecision.ASK_USER,
879
+ });
880
+
881
+ const handler = mockMessageBus.subscribe.mock.calls.find(
882
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
883
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
884
+
885
+ await handler({
886
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
887
+ correlationId: 'test-id-2',
888
+ toolCall: { name: 'rm', args: { path: '/' } },
889
+ });
890
+
891
+ expect(mockMessageBus.publish).toHaveBeenCalledWith(
892
+ expect.objectContaining({
893
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
894
+ correlationId: 'test-id-2',
895
+ confirmed: false,
896
+ requiresUserConfirmation: true,
897
+ }),
898
+ );
899
+ });
900
+
901
+ it('should deny tool calls when PolicyEngine returns DENY', async () => {
902
+ const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
903
+ check: Mock<
904
+ (
905
+ toolCall: { name: string; args: Record<string, unknown> },
906
+ serverName?: string,
907
+ toolAnnotations?: Record<string, unknown>,
908
+ subagent?: string,
909
+ ) => Promise<{ decision: PolicyDecision }>
910
+ >;
911
+ };
912
+ mockPolicyEngine.check.mockResolvedValue({
913
+ decision: PolicyDecision.DENY,
914
+ });
915
+
916
+ const handler = mockMessageBus.subscribe.mock.calls.find(
917
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
918
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
919
+
920
+ await handler({
921
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
922
+ correlationId: 'test-id-3',
923
+ toolCall: { name: 'forbidden', args: {} },
924
+ });
925
+
926
+ expect(mockMessageBus.publish).toHaveBeenCalledWith(
927
+ expect.objectContaining({
928
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
929
+ correlationId: 'test-id-3',
930
+ confirmed: false,
931
+ requiresUserConfirmation: false,
932
+ }),
933
+ );
934
+ });
935
+
936
+ it('should pass subagent and trusted tool info to PolicyEngine', async () => {
937
+ const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
938
+ check: Mock<
939
+ (
940
+ toolCall: { name: string; args: Record<string, unknown> },
941
+ serverName?: string,
942
+ toolAnnotations?: Record<string, unknown>,
943
+ subagent?: string,
944
+ ) => Promise<{ decision: PolicyDecision }>
945
+ >;
946
+ };
947
+ mockPolicyEngine.check.mockResolvedValue({
948
+ decision: PolicyDecision.ALLOW,
949
+ });
950
+
951
+ // Mock tool in registry with trusted annotations
952
+ const trustedAnnotations = { safe: true };
953
+ mockToolRegistry.getTool.mockReturnValue({
954
+ name: 'ls',
955
+ toolAnnotations: trustedAnnotations,
956
+ });
957
+
958
+ const handler = mockMessageBus.subscribe.mock.calls.find(
959
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
960
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
961
+
962
+ await handler({
963
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
964
+ correlationId: 'test-id-trusted',
965
+ toolCall: { name: 'ls', args: {} },
966
+ subagent: 'restricted-subagent',
967
+ serverName: 'spoofed-server', // Should be ignored
968
+ toolAnnotations: { malicious: true }, // Should be ignored
969
+ });
970
+
971
+ expect(mockPolicyEngine.check).toHaveBeenCalledWith(
972
+ expect.anything(),
973
+ undefined, // serverName for non-MCP tool
974
+ trustedAnnotations,
975
+ 'restricted-subagent',
976
+ );
977
+ });
978
+
979
+ it('should handle exceptions in PolicyEngine by failing closed', async () => {
980
+ const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
981
+ check: Mock<
982
+ (
983
+ toolCall: { name: string; args: Record<string, unknown> },
984
+ serverName?: string,
985
+ toolAnnotations?: Record<string, unknown>,
986
+ subagent?: string,
987
+ ) => Promise<{ decision: PolicyDecision }>
988
+ >;
989
+ };
990
+ mockPolicyEngine.check.mockRejectedValue(
991
+ new Error('Policy check failed'),
992
+ );
993
+
994
+ const handler = mockMessageBus.subscribe.mock.calls.find(
995
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
996
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
997
+
998
+ await handler({
999
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
1000
+ correlationId: 'test-id-error',
1001
+ toolCall: { name: 'ls', args: {} },
1002
+ });
1003
+
1004
+ expect(mockMessageBus.publish).toHaveBeenCalledWith(
1005
+ expect.objectContaining({
1006
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
1007
+ correlationId: 'test-id-error',
1008
+ confirmed: false,
1009
+ requiresUserConfirmation: false,
1010
+ }),
1011
+ );
1012
+ });
1013
+
1014
+ it('should fail closed when PolicyEngine is missing', async () => {
1015
+ (mockConfig.getPolicyEngine as Mock).mockReturnValue(undefined);
1016
+
1017
+ const handler = mockMessageBus.subscribe.mock.calls.find(
1018
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
1019
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
1020
+
1021
+ await handler({
1022
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
1023
+ correlationId: 'test-id-no-engine',
1024
+ toolCall: { name: 'ls', args: {} },
1025
+ });
1026
+
1027
+ expect(mockMessageBus.publish).toHaveBeenCalledWith(
1028
+ expect.objectContaining({
1029
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
1030
+ correlationId: 'test-id-no-engine',
1031
+ confirmed: false,
1032
+ requiresUserConfirmation: false,
1033
+ }),
1034
+ );
1035
+ });
1036
+
1037
+ it('should handle missing tool name in request by failing closed', async () => {
1038
+ const handler = mockMessageBus.subscribe.mock.calls.find(
1039
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
1040
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
1041
+
1042
+ await handler({
1043
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
1044
+ correlationId: 'test-id-no-name',
1045
+ toolCall: { name: '', args: {} },
1046
+ });
1047
+
1048
+ expect(mockMessageBus.publish).toHaveBeenCalledWith(
1049
+ expect.objectContaining({
1050
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
1051
+ correlationId: 'test-id-no-name',
1052
+ confirmed: false,
1053
+ requiresUserConfirmation: false,
1054
+ }),
1055
+ );
1056
+ });
1057
+
1058
+ it('should trim tool name before lookup and validation', async () => {
1059
+ const handler = mockMessageBus.subscribe.mock.calls.find(
1060
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
1061
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
1062
+
1063
+ await handler({
1064
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
1065
+ correlationId: 'test-id-whitespace',
1066
+ toolCall: { name: ' ', args: {} },
1067
+ });
1068
+
1069
+ expect(mockMessageBus.publish).toHaveBeenCalledWith(
1070
+ expect.objectContaining({
1071
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
1072
+ correlationId: 'test-id-whitespace',
1073
+ confirmed: false,
1074
+ requiresUserConfirmation: false,
1075
+ }),
1076
+ );
1077
+ });
1078
+
1079
+ it('should pass serverName from DiscoveredMCPTool to PolicyEngine', async () => {
1080
+ const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
1081
+ check: Mock<
1082
+ (
1083
+ toolCall: { name: string; args: Record<string, unknown> },
1084
+ serverName?: string,
1085
+ toolAnnotations?: Record<string, unknown>,
1086
+ subagent?: string,
1087
+ ) => Promise<{ decision: PolicyDecision }>
1088
+ >;
1089
+ };
1090
+ mockPolicyEngine.check.mockResolvedValue({
1091
+ decision: PolicyDecision.ALLOW,
1092
+ });
1093
+
1094
+ // Mock tool in registry as a DiscoveredMCPTool instance
1095
+ const mcpTool = {
1096
+ name: 'mcp_server_tool',
1097
+ serverName: 'test-server',
1098
+ toolAnnotations: { mcp: true },
1099
+ };
1100
+ Object.setPrototypeOf(mcpTool, DiscoveredMCPTool.prototype);
1101
+ mockToolRegistry.getTool.mockReturnValue(mcpTool);
1102
+
1103
+ const handler = mockMessageBus.subscribe.mock.calls.find(
1104
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
1105
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
1106
+
1107
+ await handler({
1108
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
1109
+ correlationId: 'test-id-mcp',
1110
+ toolCall: { name: 'mcp_server_tool', args: {} },
1111
+ });
1112
+
1113
+ expect(mockPolicyEngine.check).toHaveBeenCalledWith(
1114
+ expect.anything(),
1115
+ 'test-server',
1116
+ { mcp: true },
1117
+ undefined,
1118
+ );
1119
+ });
1120
+
1121
+ it('should fail closed and deny unknown tools', async () => {
1122
+ mockToolRegistry.getTool.mockReturnValue(undefined);
1123
+
1124
+ const handler = mockMessageBus.subscribe.mock.calls.find(
1125
+ (call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
1126
+ )?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
1127
+
1128
+ await handler({
1129
+ type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
1130
+ correlationId: 'test-id-unknown',
1131
+ toolCall: { name: 'unknown_tool', args: {} },
1132
+ });
1133
+
1134
+ expect(mockMessageBus.publish).toHaveBeenCalledWith(
1135
+ expect.objectContaining({
1136
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
1137
+ correlationId: 'test-id-unknown',
1138
+ confirmed: false,
1139
+ requiresUserConfirmation: false,
1140
+ }),
1141
+ );
1142
+ });
1143
+ });
1144
+ });
packages/cli/src/acp/acpSession.ts ADDED
@@ -0,0 +1,1527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ type ApprovalMode,
9
+ type ConversationRecord,
10
+ CoreToolCallStatus,
11
+ coreEvents,
12
+ CoreEvent,
13
+ type ApprovalModeChangedPayload,
14
+ logToolCall,
15
+ convertToFunctionResponse,
16
+ ToolConfirmationOutcome,
17
+ getErrorStatus,
18
+ DiscoveredMCPTool,
19
+ ToolCallEvent,
20
+ debugLogger,
21
+ ReadManyFilesTool,
22
+ partListUnionToString,
23
+ type AgentLoopContext,
24
+ updatePolicy,
25
+ getErrorMessage,
26
+ type FilterFilesOptions,
27
+ isTextPart,
28
+ GeminiEventType,
29
+ type ToolCallRequestInfo,
30
+ type GeminiChat,
31
+ type ToolResult,
32
+ isWithinRoot,
33
+ processSingleFileContent,
34
+ isNodeError,
35
+ REFERENCE_CONTENT_START,
36
+ InvalidStreamError,
37
+ MessageBusType,
38
+ PolicyDecision,
39
+ type ToolConfirmationRequest,
40
+ resolveAtCommandPath,
41
+ type ResolvedAtCommandPath,
42
+ } from '@google/gemini-cli-core';
43
+ import * as acp from '@agentclientprotocol/sdk';
44
+ import type { Part, FunctionCall } from '@google/genai';
45
+ import type { LoadedSettings } from '../config/settings.js';
46
+ import * as fs from 'node:fs/promises';
47
+ import * as path from 'node:path';
48
+ import { randomUUID } from 'node:crypto';
49
+ import { CommandHandler } from './acpCommandHandler.js';
50
+ import {
51
+ toToolCallContent,
52
+ toPermissionOptions,
53
+ toAcpToolKind,
54
+ buildAvailableModes,
55
+ RequestPermissionResponseSchema,
56
+ } from './acpUtils.js';
57
+ import { z } from 'zod';
58
+ import { getAcpErrorMessage } from './acpErrors.js';
59
+
60
+ const StructuredErrorSchema = z.object({
61
+ status: z.number().optional(),
62
+ message: z.string().optional(),
63
+ });
64
+
65
+ export class Session {
66
+ private pendingPrompt: AbortController | null = null;
67
+ private commandHandler = new CommandHandler();
68
+ private callIdCounter = 0;
69
+ private readonly disposeController = new AbortController();
70
+
71
+ private generateCallId(name: string): string {
72
+ return `${name}-${Date.now()}-${++this.callIdCounter}`;
73
+ }
74
+
75
+ constructor(
76
+ private readonly id: string,
77
+ private readonly chat: GeminiChat,
78
+ private readonly context: AgentLoopContext,
79
+ private readonly connection: acp.AgentSideConnection,
80
+ private readonly settings: LoadedSettings,
81
+ ) {
82
+ coreEvents.on(
83
+ CoreEvent.ApprovalModeChanged,
84
+ this.handleApprovalModeChanged,
85
+ );
86
+
87
+ // Subscribe to tool confirmation requests to handle policy checks (e.g. auto-allowing safe shell commands)
88
+ this.context.config
89
+ .getMessageBus()
90
+ ?.subscribe(
91
+ MessageBusType.TOOL_CONFIRMATION_REQUEST,
92
+ this.handleToolConfirmationRequest,
93
+ { signal: this.disposeController.signal },
94
+ );
95
+ }
96
+
97
+ private handleToolConfirmationRequest = async (
98
+ request: ToolConfirmationRequest,
99
+ ) => {
100
+ try {
101
+ const policyEngine = this.context.config.getPolicyEngine?.();
102
+ const messageBus = this.context.config.getMessageBus();
103
+
104
+ if (!messageBus) {
105
+ return;
106
+ }
107
+
108
+ if (!policyEngine) {
109
+ debugLogger.warn(
110
+ 'Policy engine missing. Denying tool confirmation request.',
111
+ );
112
+ await messageBus.publish({
113
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
114
+ correlationId: request.correlationId,
115
+ confirmed: false,
116
+ requiresUserConfirmation: false,
117
+ });
118
+ return;
119
+ }
120
+
121
+ const toolName = request.toolCall.name?.trim();
122
+ if (!toolName) {
123
+ debugLogger.warn(
124
+ 'Tool confirmation request missing tool name. Denying.',
125
+ );
126
+ await messageBus.publish({
127
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
128
+ correlationId: request.correlationId,
129
+ confirmed: false,
130
+ requiresUserConfirmation: false,
131
+ });
132
+ return;
133
+ }
134
+
135
+ const tool = this.context.toolRegistry.getTool(toolName);
136
+ if (!tool) {
137
+ debugLogger.warn(
138
+ `Tool confirmation request for unknown tool: ${toolName}. Denying.`,
139
+ );
140
+ await messageBus.publish({
141
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
142
+ correlationId: request.correlationId,
143
+ confirmed: false,
144
+ requiresUserConfirmation: false,
145
+ });
146
+ return;
147
+ }
148
+
149
+ const serverName =
150
+ tool instanceof DiscoveredMCPTool ? tool.serverName : undefined;
151
+ const toolAnnotations = tool.toolAnnotations;
152
+
153
+ const result = await policyEngine.check(
154
+ request.toolCall,
155
+ serverName,
156
+ toolAnnotations,
157
+ request.subagent,
158
+ );
159
+
160
+ await messageBus.publish({
161
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
162
+ correlationId: request.correlationId,
163
+ confirmed: result.decision === PolicyDecision.ALLOW,
164
+ requiresUserConfirmation: result.decision === PolicyDecision.ASK_USER,
165
+ });
166
+ } catch (error) {
167
+ debugLogger.error('Error handling tool confirmation request:', error);
168
+ // Fail closed on exception
169
+ await this.context.config.getMessageBus()?.publish({
170
+ type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
171
+ correlationId: request.correlationId,
172
+ confirmed: false,
173
+ requiresUserConfirmation: false,
174
+ });
175
+ }
176
+ };
177
+
178
+ private handleApprovalModeChanged = (payload: ApprovalModeChangedPayload) => {
179
+ if (payload.sessionId === this.id) {
180
+ void this.sendUpdate({
181
+ sessionUpdate: 'agent_message_chunk',
182
+ content: {
183
+ type: 'text',
184
+ text: `[MODE_UPDATE] ${payload.mode}`,
185
+ },
186
+ });
187
+ }
188
+ };
189
+
190
+ dispose(): void {
191
+ coreEvents.off(
192
+ CoreEvent.ApprovalModeChanged,
193
+ this.handleApprovalModeChanged,
194
+ );
195
+ this.disposeController.abort();
196
+ }
197
+
198
+ async cancelPendingPrompt(): Promise<void> {
199
+ if (!this.pendingPrompt) {
200
+ throw new Error('Not currently generating');
201
+ }
202
+
203
+ this.pendingPrompt.abort();
204
+ this.pendingPrompt = null;
205
+ }
206
+
207
+ setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse {
208
+ const availableModes = buildAvailableModes(
209
+ this.context.config.isPlanEnabled(),
210
+ );
211
+ const mode = availableModes.find((m) => m.id === modeId);
212
+ if (!mode) {
213
+ throw new Error(`Invalid or unavailable mode: ${modeId}`);
214
+ }
215
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
216
+ this.context.config.setApprovalMode(mode.id as ApprovalMode);
217
+ return {};
218
+ }
219
+
220
+ private getAvailableCommands() {
221
+ return this.commandHandler.getAvailableCommands();
222
+ }
223
+
224
+ async sendAvailableCommands(): Promise<void> {
225
+ const availableCommands = this.getAvailableCommands().map((command) => ({
226
+ name: command.name,
227
+ description: command.description,
228
+ }));
229
+
230
+ await this.sendUpdate({
231
+ sessionUpdate: 'available_commands_update',
232
+ availableCommands,
233
+ });
234
+ }
235
+
236
+ setModel(modelId: acp.ModelId): acp.SetSessionModelResponse {
237
+ this.context.config.setModel(modelId);
238
+ return {};
239
+ }
240
+
241
+ async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
242
+ for (const msg of messages) {
243
+ const contentString = partListUnionToString(msg.content);
244
+
245
+ if (msg.type === 'user') {
246
+ if (contentString.trim()) {
247
+ await this.sendUpdate({
248
+ sessionUpdate: 'user_message_chunk',
249
+ content: { type: 'text', text: contentString },
250
+ });
251
+ }
252
+ } else if (msg.type === 'gemini') {
253
+ // Thoughts
254
+ if (msg.thoughts) {
255
+ for (const thought of msg.thoughts) {
256
+ const thoughtText = `**${thought.subject}**\n${thought.description}`;
257
+ await this.sendUpdate({
258
+ sessionUpdate: 'agent_thought_chunk',
259
+ content: { type: 'text', text: thoughtText },
260
+ });
261
+ }
262
+ }
263
+
264
+ // Message text
265
+ if (contentString.trim()) {
266
+ await this.sendUpdate({
267
+ sessionUpdate: 'agent_message_chunk',
268
+ content: { type: 'text', text: contentString },
269
+ });
270
+ }
271
+
272
+ // Tool calls
273
+ if (msg.toolCalls) {
274
+ for (const toolCall of msg.toolCalls) {
275
+ const toolCallContent: acp.ToolCallContent[] = [];
276
+ if (toolCall.resultDisplay) {
277
+ if (typeof toolCall.resultDisplay === 'string') {
278
+ toolCallContent.push({
279
+ type: 'content',
280
+ content: { type: 'text', text: toolCall.resultDisplay },
281
+ });
282
+ } else if ('fileName' in toolCall.resultDisplay) {
283
+ toolCallContent.push({
284
+ type: 'diff',
285
+ path: toolCall.resultDisplay.fileName,
286
+ oldText: toolCall.resultDisplay.originalContent,
287
+ newText: toolCall.resultDisplay.newContent,
288
+ });
289
+ }
290
+ }
291
+
292
+ const tool = this.context.toolRegistry.getTool(toolCall.name);
293
+
294
+ await this.sendUpdate({
295
+ sessionUpdate: 'tool_call',
296
+ toolCallId: toolCall.id,
297
+ status:
298
+ toolCall.status === CoreToolCallStatus.Success
299
+ ? 'completed'
300
+ : 'failed',
301
+ title: toolCall.displayName || toolCall.name,
302
+ content: toolCallContent,
303
+ kind: tool ? toAcpToolKind(tool.kind) : 'other',
304
+ });
305
+ }
306
+ }
307
+ }
308
+ }
309
+ }
310
+
311
+ async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
312
+ this.pendingPrompt?.abort();
313
+ const pendingSend = new AbortController();
314
+ this.pendingPrompt = pendingSend;
315
+
316
+ await this.context.config.waitForMcpInit();
317
+
318
+ const promptId = Math.random().toString(16).slice(2);
319
+
320
+ const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
321
+
322
+ // Command interception
323
+ let commandText = '';
324
+
325
+ for (const part of parts) {
326
+ if (typeof part === 'object' && part !== null) {
327
+ if (isTextPart(part)) {
328
+ // It is a text part
329
+ const text = part.text;
330
+ commandText += text;
331
+ } else {
332
+ // Non-text part (image, embedded resource)
333
+ // Stop looking for command
334
+ break;
335
+ }
336
+ }
337
+ }
338
+
339
+ commandText = commandText.trim();
340
+
341
+ if (
342
+ commandText &&
343
+ (commandText.startsWith('/') || commandText.startsWith('$'))
344
+ ) {
345
+ // If we found a command, pass it to handleCommand
346
+ // Note: handleCommand currently expects `commandText` to be the command string
347
+ // It uses `parts` argument but effectively ignores it in current implementation
348
+ const handled = await this.handleCommand(commandText, parts);
349
+ if (handled) {
350
+ return {
351
+ stopReason: 'end_turn',
352
+ _meta: {
353
+ quota: {
354
+ token_count: { input_tokens: 0, output_tokens: 0 },
355
+ model_usage: [],
356
+ },
357
+ },
358
+ };
359
+ }
360
+ }
361
+
362
+ let totalInputTokens = 0;
363
+ let totalOutputTokens = 0;
364
+ const modelUsageMap = new Map<string, { input: number; output: number }>();
365
+
366
+ let currentParts: Part[] = parts;
367
+ let turnCount = 0;
368
+ const maxTurns = this.context.config.getMaxSessionTurns();
369
+
370
+ while (true) {
371
+ turnCount++;
372
+ if (maxTurns >= 0 && turnCount > maxTurns) {
373
+ return {
374
+ stopReason: 'max_turn_requests',
375
+ _meta: {
376
+ quota: {
377
+ token_count: {
378
+ input_tokens: totalInputTokens,
379
+ output_tokens: totalOutputTokens,
380
+ },
381
+ model_usage: Array.from(modelUsageMap.entries()).map(
382
+ ([modelName, counts]) => ({
383
+ model: modelName,
384
+ token_count: {
385
+ input_tokens: counts.input,
386
+ output_tokens: counts.output,
387
+ },
388
+ }),
389
+ ),
390
+ },
391
+ },
392
+ };
393
+ }
394
+
395
+ if (pendingSend.signal.aborted) {
396
+ return { stopReason: 'cancelled' };
397
+ }
398
+
399
+ const toolCallRequests: ToolCallRequestInfo[] = [];
400
+ let stopReason: acp.StopReason = 'end_turn';
401
+ let turnModelId = this.context.config.getModel();
402
+ let turnInputTokens = 0;
403
+ let turnOutputTokens = 0;
404
+
405
+ try {
406
+ const responseStream = this.context.geminiClient.sendMessageStream(
407
+ currentParts,
408
+ pendingSend.signal,
409
+ promptId,
410
+ );
411
+
412
+ for await (const event of responseStream) {
413
+ if (pendingSend.signal.aborted) {
414
+ return { stopReason: 'cancelled' };
415
+ }
416
+
417
+ switch (event.type) {
418
+ case GeminiEventType.Content: {
419
+ const content: acp.ContentBlock = {
420
+ type: 'text',
421
+ text: event.value,
422
+ };
423
+
424
+ await this.sendUpdate({
425
+ sessionUpdate: 'agent_message_chunk',
426
+ content,
427
+ });
428
+ break;
429
+ }
430
+
431
+ case GeminiEventType.Thought: {
432
+ const thoughtText = `**${event.value.subject}**\n${event.value.description}`;
433
+ await this.sendUpdate({
434
+ sessionUpdate: 'agent_thought_chunk',
435
+ content: { type: 'text', text: thoughtText },
436
+ });
437
+ break;
438
+ }
439
+
440
+ case GeminiEventType.ToolCallRequest:
441
+ toolCallRequests.push(event.value);
442
+ break;
443
+
444
+ case GeminiEventType.Finished: {
445
+ const usage = event.value.usageMetadata;
446
+ if (usage) {
447
+ turnInputTokens = usage.promptTokenCount ?? turnInputTokens;
448
+ turnOutputTokens =
449
+ usage.candidatesTokenCount ?? turnOutputTokens;
450
+ }
451
+ break;
452
+ }
453
+
454
+ case GeminiEventType.ModelInfo:
455
+ turnModelId = event.value;
456
+ break;
457
+
458
+ case GeminiEventType.MaxSessionTurns:
459
+ stopReason = 'max_turn_requests';
460
+ break;
461
+
462
+ case GeminiEventType.LoopDetected:
463
+ stopReason = 'max_turn_requests';
464
+ break;
465
+
466
+ case GeminiEventType.ContextWindowWillOverflow:
467
+ stopReason = 'max_tokens';
468
+ break;
469
+
470
+ case GeminiEventType.Error: {
471
+ const parseResult = StructuredErrorSchema.safeParse(
472
+ event.value.error,
473
+ );
474
+ const errData = parseResult.success ? parseResult.data : {};
475
+
476
+ throw new acp.RequestError(
477
+ errData.status ?? 500,
478
+ errData.message ?? 'Unknown stream execution error.',
479
+ );
480
+ }
481
+
482
+ default:
483
+ break;
484
+ }
485
+ }
486
+ } catch (error) {
487
+ if (getErrorStatus(error) === 429) {
488
+ throw new acp.RequestError(
489
+ 429,
490
+ 'Rate limit exceeded. Try again later.',
491
+ );
492
+ }
493
+
494
+ if (
495
+ pendingSend.signal.aborted ||
496
+ (error instanceof Error && error.name === 'AbortError')
497
+ ) {
498
+ return { stopReason: 'cancelled' };
499
+ }
500
+
501
+ if (error instanceof acp.RequestError) {
502
+ throw error;
503
+ }
504
+
505
+ if (
506
+ error instanceof InvalidStreamError ||
507
+ (error &&
508
+ typeof error === 'object' &&
509
+ 'type' in error &&
510
+ (error.type === 'NO_RESPONSE_TEXT' ||
511
+ error.type === 'NO_FINISH_REASON' ||
512
+ error.type === 'MALFORMED_FUNCTION_CALL' ||
513
+ error.type === 'UNEXPECTED_TOOL_CALL' ||
514
+ error.type === 'MAX_TOKENS_EXCEEDED' ||
515
+ error.type === 'SAFETY_BLOCKED' ||
516
+ error.type === 'RECITATION_BLOCKED' ||
517
+ error.type === 'OTHER_BLOCKED' ||
518
+ error.type === 'THINKING_ONLY_RESPONSE'))
519
+ ) {
520
+ // The stream ended with an empty response or malformed tool call.
521
+ // Treat this as a graceful end to the model's turn rather than a crash.
522
+ return {
523
+ stopReason: 'end_turn',
524
+ _meta: {
525
+ quota: {
526
+ token_count: {
527
+ input_tokens: totalInputTokens,
528
+ output_tokens: totalOutputTokens,
529
+ },
530
+ model_usage: Array.from(modelUsageMap.entries()).map(
531
+ ([modelName, counts]) => ({
532
+ model: modelName,
533
+ token_count: {
534
+ input_tokens: counts.input,
535
+ output_tokens: counts.output,
536
+ },
537
+ }),
538
+ ),
539
+ },
540
+ },
541
+ };
542
+ }
543
+
544
+ throw new acp.RequestError(
545
+ getErrorStatus(error) || 500,
546
+ getAcpErrorMessage(error),
547
+ );
548
+ }
549
+
550
+ totalInputTokens += turnInputTokens;
551
+ totalOutputTokens += turnOutputTokens;
552
+
553
+ if (turnInputTokens > 0 || turnOutputTokens > 0) {
554
+ const existing = modelUsageMap.get(turnModelId) ?? {
555
+ input: 0,
556
+ output: 0,
557
+ };
558
+ existing.input += turnInputTokens;
559
+ existing.output += turnOutputTokens;
560
+ modelUsageMap.set(turnModelId, existing);
561
+ }
562
+
563
+ if (stopReason !== 'end_turn') {
564
+ return {
565
+ stopReason,
566
+ _meta: {
567
+ quota: {
568
+ token_count: {
569
+ input_tokens: totalInputTokens,
570
+ output_tokens: totalOutputTokens,
571
+ },
572
+ model_usage: Array.from(modelUsageMap.entries()).map(
573
+ ([modelName, counts]) => ({
574
+ model: modelName,
575
+ token_count: {
576
+ input_tokens: counts.input,
577
+ output_tokens: counts.output,
578
+ },
579
+ }),
580
+ ),
581
+ },
582
+ },
583
+ };
584
+ }
585
+
586
+ if (toolCallRequests.length === 0) {
587
+ break;
588
+ }
589
+
590
+ const toolResponseParts: Part[] = [];
591
+ for (const tReq of toolCallRequests) {
592
+ const fc: FunctionCall = {
593
+ id: tReq.callId,
594
+ name: tReq.name,
595
+ args: tReq.args,
596
+ };
597
+
598
+ const response = await this.runTool(pendingSend.signal, promptId, fc);
599
+ toolResponseParts.push(...response);
600
+ }
601
+
602
+ currentParts = toolResponseParts;
603
+ }
604
+
605
+ const modelUsageArray = Array.from(modelUsageMap.entries()).map(
606
+ ([modelName, counts]) => ({
607
+ model: modelName,
608
+ token_count: {
609
+ input_tokens: counts.input,
610
+ output_tokens: counts.output,
611
+ },
612
+ }),
613
+ );
614
+
615
+ return {
616
+ stopReason: 'end_turn',
617
+ _meta: {
618
+ quota: {
619
+ token_count: {
620
+ input_tokens: totalInputTokens,
621
+ output_tokens: totalOutputTokens,
622
+ },
623
+ model_usage: modelUsageArray,
624
+ },
625
+ },
626
+ };
627
+ }
628
+
629
+ private async handleCommand(
630
+ commandText: string,
631
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
632
+ parts: Part[],
633
+ ): Promise<boolean> {
634
+ const gitService = await this.context.config.getGitService();
635
+ const commandContext = {
636
+ agentContext: this.context,
637
+ settings: this.settings,
638
+ git: gitService,
639
+ sendMessage: async (text: string) => {
640
+ await this.sendUpdate({
641
+ sessionUpdate: 'agent_message_chunk',
642
+ content: { type: 'text', text },
643
+ });
644
+ },
645
+ };
646
+
647
+ return this.commandHandler.handleCommand(commandText, commandContext);
648
+ }
649
+
650
+ private async sendUpdate(update: acp.SessionUpdate): Promise<void> {
651
+ const params: acp.SessionNotification = {
652
+ sessionId: this.id,
653
+ update,
654
+ };
655
+
656
+ await this.connection.sessionUpdate(params);
657
+ }
658
+
659
+ private async runTool(
660
+ abortSignal: AbortSignal,
661
+ promptId: string,
662
+ fc: FunctionCall,
663
+ ): Promise<Part[]> {
664
+ const callId = fc.id ?? this.generateCallId(fc.name || 'unknown');
665
+ const args = fc.args ?? {};
666
+
667
+ const startTime = Date.now();
668
+
669
+ const errorResponse = (error: Error) => {
670
+ const durationMs = Date.now() - startTime;
671
+ logToolCall(
672
+ this.context.config,
673
+ new ToolCallEvent(
674
+ undefined,
675
+ fc.name ?? '',
676
+ args,
677
+ durationMs,
678
+ false,
679
+ promptId,
680
+ typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
681
+ ? 'mcp'
682
+ : 'native',
683
+ error.message,
684
+ ),
685
+ );
686
+
687
+ return [
688
+ {
689
+ functionResponse: {
690
+ id: callId,
691
+ name: fc.name ?? '',
692
+ response: { error: error.message },
693
+ },
694
+ },
695
+ ];
696
+ };
697
+
698
+ if (!fc.name) {
699
+ return errorResponse(new Error('Missing function name'));
700
+ }
701
+
702
+ const toolRegistry = this.context.toolRegistry;
703
+ const tool = toolRegistry.getTool(fc.name);
704
+
705
+ if (!tool) {
706
+ return errorResponse(
707
+ new Error(`Tool "${fc.name}" not found in registry.`),
708
+ );
709
+ }
710
+
711
+ try {
712
+ const invocation = tool.build(args);
713
+
714
+ const displayTitle =
715
+ typeof invocation.getDisplayTitle === 'function'
716
+ ? invocation.getDisplayTitle()
717
+ : invocation.getDescription();
718
+
719
+ const explanation =
720
+ typeof invocation.getExplanation === 'function'
721
+ ? invocation.getExplanation()
722
+ : '';
723
+
724
+ const confirmationDetails =
725
+ await invocation.shouldConfirmExecute(abortSignal);
726
+
727
+ if (confirmationDetails) {
728
+ const content: acp.ToolCallContent[] = [];
729
+
730
+ if (confirmationDetails.type === 'edit') {
731
+ content.push({
732
+ type: 'diff',
733
+ path: confirmationDetails.filePath,
734
+ oldText: confirmationDetails.originalContent,
735
+ newText: confirmationDetails.newContent,
736
+ _meta: {
737
+ kind: !confirmationDetails.originalContent
738
+ ? 'add'
739
+ : confirmationDetails.newContent === ''
740
+ ? 'delete'
741
+ : 'modify',
742
+ },
743
+ });
744
+ }
745
+
746
+ if (explanation) {
747
+ content.push({
748
+ type: 'content',
749
+ content: { type: 'text', text: explanation },
750
+ });
751
+ }
752
+
753
+ const params: acp.RequestPermissionRequest = {
754
+ sessionId: this.id,
755
+ options: toPermissionOptions(
756
+ confirmationDetails,
757
+ this.context.config,
758
+ this.settings.merged.security.enablePermanentToolApproval,
759
+ ),
760
+ toolCall: {
761
+ toolCallId: callId,
762
+ status: 'pending',
763
+ title: displayTitle,
764
+ content,
765
+ locations: invocation.toolLocations(),
766
+ kind: toAcpToolKind(tool.kind),
767
+ },
768
+ };
769
+
770
+ const output = RequestPermissionResponseSchema.parse(
771
+ await this.connection.requestPermission(params),
772
+ );
773
+
774
+ const outcome =
775
+ output.outcome.outcome === 'cancelled'
776
+ ? ToolConfirmationOutcome.Cancel
777
+ : z
778
+ .nativeEnum(ToolConfirmationOutcome)
779
+ .parse(output.outcome.optionId);
780
+
781
+ await confirmationDetails.onConfirm(outcome);
782
+
783
+ // Update policy to enable Always Allow persistence
784
+ await updatePolicy(
785
+ tool,
786
+ outcome,
787
+ confirmationDetails,
788
+ this.context,
789
+ this.context.messageBus,
790
+ invocation,
791
+ );
792
+
793
+ switch (outcome) {
794
+ case ToolConfirmationOutcome.Cancel:
795
+ return errorResponse(
796
+ new Error(`Tool "${fc.name}" was canceled by the user.`),
797
+ );
798
+ case ToolConfirmationOutcome.ProceedOnce:
799
+ case ToolConfirmationOutcome.ProceedAlways:
800
+ case ToolConfirmationOutcome.ProceedAlwaysAndSave:
801
+ case ToolConfirmationOutcome.ProceedAlwaysServer:
802
+ case ToolConfirmationOutcome.ProceedAlwaysTool:
803
+ case ToolConfirmationOutcome.ModifyWithEditor:
804
+ break;
805
+ default: {
806
+ const resultOutcome: never = outcome;
807
+ throw new Error(`Unexpected: ${resultOutcome}`);
808
+ }
809
+ }
810
+ } else {
811
+ const content: acp.ToolCallContent[] = [];
812
+
813
+ if (explanation) {
814
+ content.push({
815
+ type: 'content',
816
+ content: { type: 'text', text: explanation },
817
+ });
818
+ }
819
+
820
+ await this.sendUpdate({
821
+ sessionUpdate: 'tool_call',
822
+ toolCallId: callId,
823
+ status: 'in_progress',
824
+ title: displayTitle,
825
+ content,
826
+ locations: invocation.toolLocations(),
827
+ kind: toAcpToolKind(tool.kind),
828
+ });
829
+ }
830
+
831
+ const toolResult: ToolResult = await invocation.execute({
832
+ abortSignal,
833
+ });
834
+ const content = toToolCallContent(toolResult);
835
+
836
+ const updateContent: acp.ToolCallContent[] = content ? [content] : [];
837
+
838
+ await this.sendUpdate({
839
+ sessionUpdate: 'tool_call_update',
840
+ toolCallId: callId,
841
+ status: 'completed',
842
+ title: displayTitle,
843
+ content: updateContent,
844
+ locations: invocation.toolLocations(),
845
+ kind: toAcpToolKind(tool.kind),
846
+ });
847
+
848
+ const durationMs = Date.now() - startTime;
849
+ logToolCall(
850
+ this.context.config,
851
+ new ToolCallEvent(
852
+ undefined,
853
+ fc.name ?? '',
854
+ args,
855
+ durationMs,
856
+ true,
857
+ promptId,
858
+ typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
859
+ ? 'mcp'
860
+ : 'native',
861
+ ),
862
+ );
863
+
864
+ this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [
865
+ {
866
+ status: CoreToolCallStatus.Success,
867
+ request: {
868
+ callId,
869
+ name: fc.name,
870
+ args,
871
+ isClientInitiated: false,
872
+ prompt_id: promptId,
873
+ },
874
+ tool,
875
+ invocation,
876
+ response: {
877
+ callId,
878
+ responseParts: convertToFunctionResponse(
879
+ fc.name,
880
+ callId,
881
+ toolResult.llmContent,
882
+ this.context.config.getActiveModel(),
883
+ this.context.config,
884
+ ),
885
+ resultDisplay: toolResult.returnDisplay,
886
+ error: undefined,
887
+ errorType: undefined,
888
+ },
889
+ },
890
+ ]);
891
+
892
+ return convertToFunctionResponse(
893
+ fc.name,
894
+ callId,
895
+ toolResult.llmContent,
896
+ this.context.config.getActiveModel(),
897
+ this.context.config,
898
+ );
899
+ } catch (e) {
900
+ const error = e instanceof Error ? e : new Error(String(e));
901
+
902
+ await this.sendUpdate({
903
+ sessionUpdate: 'tool_call_update',
904
+ toolCallId: callId,
905
+ status: 'failed',
906
+ content: [
907
+ { type: 'content', content: { type: 'text', text: error.message } },
908
+ ],
909
+ kind: toAcpToolKind(tool.kind),
910
+ });
911
+
912
+ this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [
913
+ {
914
+ status: CoreToolCallStatus.Error,
915
+ request: {
916
+ callId,
917
+ name: fc.name,
918
+ args,
919
+ isClientInitiated: false,
920
+ prompt_id: promptId,
921
+ },
922
+ tool,
923
+ response: {
924
+ callId,
925
+ responseParts: [
926
+ {
927
+ functionResponse: {
928
+ id: callId,
929
+ name: fc.name ?? '',
930
+ response: { error: error.message },
931
+ },
932
+ },
933
+ ],
934
+ resultDisplay: error.message,
935
+ error,
936
+ errorType: undefined,
937
+ },
938
+ },
939
+ ]);
940
+
941
+ return errorResponse(error);
942
+ }
943
+ }
944
+
945
+ async #resolvePrompt(
946
+ message: acp.ContentBlock[],
947
+ abortSignal: AbortSignal,
948
+ ): Promise<Part[]> {
949
+ const FILE_URI_SCHEME = 'file://';
950
+
951
+ const embeddedContext: acp.EmbeddedResourceResource[] = [];
952
+
953
+ const parts = message.map((part) => {
954
+ switch (part.type) {
955
+ case 'text':
956
+ return { text: part.text };
957
+ case 'image':
958
+ case 'audio':
959
+ return {
960
+ inlineData: {
961
+ mimeType: part.mimeType,
962
+ data: part.data,
963
+ },
964
+ };
965
+ case 'resource_link': {
966
+ if (part.uri.startsWith(FILE_URI_SCHEME)) {
967
+ return {
968
+ fileData: {
969
+ mimeData: part.mimeType,
970
+ name: part.name,
971
+ fileUri: part.uri.slice(FILE_URI_SCHEME.length),
972
+ },
973
+ };
974
+ } else {
975
+ return { text: `@${part.uri}` };
976
+ }
977
+ }
978
+ case 'resource': {
979
+ embeddedContext.push(part.resource);
980
+ return { text: `@${part.resource.uri}` };
981
+ }
982
+ default: {
983
+ const unreachable: never = part;
984
+ throw new Error(`Unexpected chunk type: '${unreachable}'`);
985
+ }
986
+ }
987
+ });
988
+
989
+ const atPathCommandParts = parts.filter((part) => 'fileData' in part);
990
+
991
+ if (atPathCommandParts.length === 0 && embeddedContext.length === 0) {
992
+ return parts;
993
+ }
994
+
995
+ const atPathToResolvedSpecMap = new Map<string, string>();
996
+
997
+ // Get centralized file discovery service
998
+ const fileDiscovery = this.context.config.getFileService();
999
+ const fileFilteringOptions: FilterFilesOptions =
1000
+ this.context.config.getFileFilteringOptions();
1001
+
1002
+ const pathSpecsToRead: string[] = [];
1003
+ const contentLabelsForDisplay: string[] = [];
1004
+ const ignoredPaths: string[] = [];
1005
+ const directContents: Array<{
1006
+ spec: string;
1007
+ content?: string;
1008
+ part?: Part;
1009
+ }> = [];
1010
+
1011
+ const toolRegistry = this.context.toolRegistry;
1012
+ const readManyFilesTool = new ReadManyFilesTool(
1013
+ this.context.config,
1014
+ this.context.messageBus,
1015
+ );
1016
+ const globTool = toolRegistry.getTool('glob');
1017
+
1018
+ if (!readManyFilesTool) {
1019
+ throw new Error('Error: read_many_files tool not found.');
1020
+ }
1021
+
1022
+ for (const atPathPart of atPathCommandParts) {
1023
+ const pathName = atPathPart.fileData!.fileUri;
1024
+ // Check if path should be ignored
1025
+ if (fileDiscovery.shouldIgnoreFile(pathName, fileFilteringOptions)) {
1026
+ ignoredPaths.push(pathName);
1027
+ debugLogger.warn(`Path ${pathName} is ignored and will be skipped.`);
1028
+ continue;
1029
+ }
1030
+ let currentPathSpec = pathName;
1031
+ let resolvedSuccessfully = false;
1032
+ let readDirectly = false;
1033
+
1034
+ const result = await resolveAtCommandPath(
1035
+ pathName,
1036
+ this.context.config,
1037
+ (msg) => this.debug(msg),
1038
+ );
1039
+
1040
+ let validationError: string | null = null;
1041
+ let absolutePath: string;
1042
+ let resolved: ResolvedAtCommandPath | undefined;
1043
+
1044
+ if (result.status === 'resolved') {
1045
+ resolved = result.resolved;
1046
+ absolutePath = resolved.absolutePath;
1047
+ } else if (result.status === 'unauthorized') {
1048
+ absolutePath = result.absolutePath;
1049
+ validationError = result.error;
1050
+ } else if (result.status === 'invalid') {
1051
+ // Already logged in resolveAtCommandPath
1052
+ continue;
1053
+ } else {
1054
+ // Result is not_found.
1055
+ // We still check if it's an unauthorized absolute path that we can ask permission for,
1056
+ // specifically for paths that are completely outside the root and not even in any workspace directory.
1057
+ // For relative paths not found anywhere, we resolve relative to targetDir for permission check.
1058
+ absolutePath = path.resolve(
1059
+ this.context.config.getTargetDir(),
1060
+ pathName,
1061
+ );
1062
+ }
1063
+
1064
+ if (
1065
+ !resolved &&
1066
+ validationError &&
1067
+ !isWithinRoot(absolutePath, this.context.config.getTargetDir())
1068
+ ) {
1069
+ try {
1070
+ const stats = await fs.stat(absolutePath);
1071
+ if (stats.isFile()) {
1072
+ const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
1073
+ const params = {
1074
+ sessionId: this.id,
1075
+ options: [
1076
+ {
1077
+ optionId: ToolConfirmationOutcome.ProceedOnce,
1078
+ name: 'Allow once',
1079
+ kind: 'allow_once',
1080
+ },
1081
+ {
1082
+ optionId: ToolConfirmationOutcome.Cancel,
1083
+ name: 'Deny',
1084
+ kind: 'reject_once',
1085
+ },
1086
+ ] as acp.PermissionOption[],
1087
+ toolCall: {
1088
+ toolCallId: syntheticCallId,
1089
+ status: 'pending',
1090
+ title: `Allow access to absolute path: ${pathName}`,
1091
+ content: [
1092
+ {
1093
+ type: 'content',
1094
+ content: {
1095
+ type: 'text',
1096
+ text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
1097
+ },
1098
+ },
1099
+ ],
1100
+ locations: [],
1101
+ kind: 'read',
1102
+ },
1103
+ };
1104
+
1105
+ const output = RequestPermissionResponseSchema.parse(
1106
+ await this.connection.requestPermission(params),
1107
+ );
1108
+
1109
+ const outcome =
1110
+ output.outcome.outcome === 'cancelled'
1111
+ ? ToolConfirmationOutcome.Cancel
1112
+ : z
1113
+ .nativeEnum(ToolConfirmationOutcome)
1114
+ .parse(output.outcome.optionId);
1115
+
1116
+ if (outcome === ToolConfirmationOutcome.ProceedOnce) {
1117
+ this.context.config
1118
+ .getWorkspaceContext()
1119
+ .addReadOnlyPath(absolutePath);
1120
+ validationError = null;
1121
+ } else {
1122
+ this.debug(
1123
+ `Direct read authorization denied for absolute path ${pathName}`,
1124
+ );
1125
+ directContents.push({
1126
+ spec: pathName,
1127
+ content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
1128
+ });
1129
+ continue;
1130
+ }
1131
+ }
1132
+ } catch (error) {
1133
+ this.debug(
1134
+ `Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
1135
+ );
1136
+ await this.sendUpdate({
1137
+ sessionUpdate: 'agent_thought_chunk',
1138
+ content: {
1139
+ type: 'text',
1140
+ text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
1141
+ },
1142
+ });
1143
+ }
1144
+ }
1145
+
1146
+ try {
1147
+ if (!validationError) {
1148
+ // If it's an absolute path that is authorized (e.g. added via readOnlyPaths),
1149
+ // read it directly to avoid ReadManyFilesTool absolute path resolution issues.
1150
+ if (
1151
+ (path.isAbsolute(pathName) ||
1152
+ !isWithinRoot(
1153
+ absolutePath,
1154
+ this.context.config.getTargetDir(),
1155
+ )) &&
1156
+ !readDirectly
1157
+ ) {
1158
+ try {
1159
+ const stats = resolved
1160
+ ? resolved.stats
1161
+ : await fs.stat(absolutePath);
1162
+ if (stats.isFile()) {
1163
+ const fileReadResult = await processSingleFileContent(
1164
+ absolutePath,
1165
+ this.context.config.getTargetDir(),
1166
+ this.context.config.getFileSystemService(),
1167
+ );
1168
+
1169
+ if (!fileReadResult.error) {
1170
+ if (
1171
+ typeof fileReadResult.llmContent === 'object' &&
1172
+ 'inlineData' in fileReadResult.llmContent
1173
+ ) {
1174
+ directContents.push({
1175
+ spec: pathName,
1176
+ part: fileReadResult.llmContent,
1177
+ });
1178
+ } else if (typeof fileReadResult.llmContent === 'string') {
1179
+ let contentToPush = fileReadResult.llmContent;
1180
+ if (fileReadResult.isTruncated) {
1181
+ contentToPush = `[WARNING: This file was truncated]\n\n${contentToPush}`;
1182
+ }
1183
+ directContents.push({
1184
+ spec: pathName,
1185
+ content: contentToPush,
1186
+ });
1187
+ }
1188
+ readDirectly = true;
1189
+ resolvedSuccessfully = true;
1190
+ } else {
1191
+ this.debug(
1192
+ `Direct read failed for absolute path ${pathName}: ${fileReadResult.error}`,
1193
+ );
1194
+ await this.sendUpdate({
1195
+ sessionUpdate: 'agent_thought_chunk',
1196
+ content: {
1197
+ type: 'text',
1198
+ text: `Warning: file read failed for \`${pathName}\`. Reason: ${fileReadResult.error}`,
1199
+ },
1200
+ });
1201
+ continue;
1202
+ }
1203
+ }
1204
+ } catch (error) {
1205
+ this.debug(
1206
+ `File stat/access error for absolute path ${pathName}: ${getErrorMessage(error)}`,
1207
+ );
1208
+ await this.sendUpdate({
1209
+ sessionUpdate: 'agent_thought_chunk',
1210
+ content: {
1211
+ type: 'text',
1212
+ text: `Warning: file access failed for \`${pathName}\`. Reason: ${getErrorMessage(error)}`,
1213
+ },
1214
+ });
1215
+ continue;
1216
+ }
1217
+ }
1218
+
1219
+ if (!readDirectly) {
1220
+ const stats = resolved
1221
+ ? resolved.stats
1222
+ : await fs.stat(absolutePath);
1223
+ if (stats.isDirectory()) {
1224
+ currentPathSpec = pathName.endsWith('/')
1225
+ ? `${pathName}**`
1226
+ : `${pathName}/**`;
1227
+ this.debug(
1228
+ `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
1229
+ );
1230
+ } else {
1231
+ this.debug(
1232
+ `Path ${pathName} resolved to file: ${currentPathSpec}`,
1233
+ );
1234
+ }
1235
+ resolvedSuccessfully = true;
1236
+ }
1237
+ } else {
1238
+ this.debug(
1239
+ `Path ${pathName} access disallowed: ${validationError}. Skipping.`,
1240
+ );
1241
+ await this.sendUpdate({
1242
+ sessionUpdate: 'agent_thought_chunk',
1243
+ content: {
1244
+ type: 'text',
1245
+ text: `Warning: skipping access to \`${pathName}\`. Reason: ${validationError}`,
1246
+ },
1247
+ });
1248
+ }
1249
+ } catch (error) {
1250
+ if (isNodeError(error) && error.code === 'ENOENT') {
1251
+ if (this.context.config.getEnableRecursiveFileSearch() && globTool) {
1252
+ this.debug(
1253
+ `Path ${pathName} not found directly, attempting glob search.`,
1254
+ );
1255
+ try {
1256
+ const globResult = await globTool.buildAndExecute(
1257
+ {
1258
+ pattern: `**/*${pathName}*`,
1259
+ path: this.context.config.getTargetDir(),
1260
+ },
1261
+ abortSignal,
1262
+ );
1263
+ if (
1264
+ globResult.llmContent &&
1265
+ typeof globResult.llmContent === 'string' &&
1266
+ !globResult.llmContent.startsWith('No files found') &&
1267
+ !globResult.llmContent.startsWith('Error:')
1268
+ ) {
1269
+ const lines = globResult.llmContent.split('\n');
1270
+ if (lines.length > 1 && lines[1]) {
1271
+ const firstMatchAbsolute = lines[1].trim();
1272
+ currentPathSpec = path.relative(
1273
+ this.context.config.getTargetDir(),
1274
+ firstMatchAbsolute,
1275
+ );
1276
+ this.debug(
1277
+ `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
1278
+ );
1279
+ resolvedSuccessfully = true;
1280
+ } else {
1281
+ this.debug(
1282
+ `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
1283
+ );
1284
+ }
1285
+ } else {
1286
+ this.debug(
1287
+ `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
1288
+ );
1289
+ }
1290
+ } catch (globError) {
1291
+ debugLogger.error(
1292
+ `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
1293
+ );
1294
+ }
1295
+ } else {
1296
+ this.debug(
1297
+ `Glob tool not found. Path ${pathName} will be skipped.`,
1298
+ );
1299
+ }
1300
+ } else {
1301
+ debugLogger.error(
1302
+ `Error stating path ${pathName}. Path ${pathName} will be skipped.`,
1303
+ );
1304
+ }
1305
+ }
1306
+ if (resolvedSuccessfully) {
1307
+ if (!readDirectly) {
1308
+ pathSpecsToRead.push(currentPathSpec);
1309
+ }
1310
+ atPathToResolvedSpecMap.set(pathName, currentPathSpec);
1311
+ contentLabelsForDisplay.push(pathName);
1312
+ }
1313
+ }
1314
+
1315
+ // Construct the initial part of the query for the LLM
1316
+ let initialQueryText = '';
1317
+ for (let i = 0; i < parts.length; i++) {
1318
+ const chunk = parts[i];
1319
+ if ('text' in chunk) {
1320
+ initialQueryText += chunk.text;
1321
+ } else {
1322
+ // type === 'atPath'
1323
+ const resolvedSpec =
1324
+ chunk.fileData && atPathToResolvedSpecMap.get(chunk.fileData.fileUri);
1325
+ if (
1326
+ i > 0 &&
1327
+ initialQueryText.length > 0 &&
1328
+ !initialQueryText.endsWith(' ') &&
1329
+ resolvedSpec
1330
+ ) {
1331
+ // Add space if previous part was text and didn't end with space, or if previous was @path
1332
+ const prevPart = parts[i - 1];
1333
+ if (
1334
+ 'text' in prevPart ||
1335
+ ('fileData' in prevPart &&
1336
+ atPathToResolvedSpecMap.has(prevPart.fileData!.fileUri))
1337
+ ) {
1338
+ initialQueryText += ' ';
1339
+ }
1340
+ }
1341
+ if (resolvedSpec) {
1342
+ initialQueryText += `@${resolvedSpec}`;
1343
+ } else {
1344
+ // If not resolved for reading (e.g. lone @ or invalid path that was skipped),
1345
+ // add the original @-string back, ensuring spacing if it's not the first element.
1346
+ if (
1347
+ i > 0 &&
1348
+ initialQueryText.length > 0 &&
1349
+ !initialQueryText.endsWith(' ') &&
1350
+ !chunk.fileData?.fileUri.startsWith(' ')
1351
+ ) {
1352
+ initialQueryText += ' ';
1353
+ }
1354
+ if (chunk.fileData?.fileUri) {
1355
+ initialQueryText += `@${chunk.fileData.fileUri}`;
1356
+ }
1357
+ }
1358
+ }
1359
+ }
1360
+ initialQueryText = initialQueryText.trim();
1361
+ // Inform user about ignored paths
1362
+ if (ignoredPaths.length > 0) {
1363
+ this.debug(
1364
+ `Ignored ${ignoredPaths.length} files: ${ignoredPaths.join(', ')}`,
1365
+ );
1366
+ }
1367
+
1368
+ const processedQueryParts: Part[] = [{ text: initialQueryText }];
1369
+
1370
+ if (
1371
+ pathSpecsToRead.length === 0 &&
1372
+ embeddedContext.length === 0 &&
1373
+ directContents.length === 0
1374
+ ) {
1375
+ // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
1376
+ debugLogger.warn('No valid file paths found in @ commands to read.');
1377
+ return [{ text: initialQueryText }];
1378
+ }
1379
+
1380
+ if (pathSpecsToRead.length > 0) {
1381
+ const toolArgs = {
1382
+ include: pathSpecsToRead,
1383
+ };
1384
+
1385
+ const callId = this.generateCallId(readManyFilesTool.name);
1386
+
1387
+ try {
1388
+ const invocation = readManyFilesTool.build(toolArgs);
1389
+
1390
+ const displayTitle =
1391
+ typeof invocation.getDisplayTitle === 'function'
1392
+ ? invocation.getDisplayTitle()
1393
+ : invocation.getDescription();
1394
+
1395
+ await this.sendUpdate({
1396
+ sessionUpdate: 'tool_call',
1397
+ toolCallId: callId,
1398
+ status: 'in_progress',
1399
+ title: displayTitle,
1400
+ content: [],
1401
+ locations: invocation.toolLocations(),
1402
+ kind: toAcpToolKind(readManyFilesTool.kind),
1403
+ });
1404
+
1405
+ const result = await invocation.execute({ abortSignal });
1406
+ const content = toToolCallContent(result) || {
1407
+ type: 'content',
1408
+ content: {
1409
+ type: 'text',
1410
+ text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
1411
+ },
1412
+ };
1413
+ await this.sendUpdate({
1414
+ sessionUpdate: 'tool_call_update',
1415
+ toolCallId: callId,
1416
+ status: 'completed',
1417
+ title: displayTitle,
1418
+ content: content ? [content] : [],
1419
+ locations: invocation.toolLocations(),
1420
+ kind: toAcpToolKind(readManyFilesTool.kind),
1421
+ });
1422
+ if (Array.isArray(result.llmContent)) {
1423
+ const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
1424
+ processedQueryParts.push({
1425
+ text: `\n${REFERENCE_CONTENT_START}`,
1426
+ });
1427
+ for (const part of result.llmContent) {
1428
+ if (typeof part === 'string') {
1429
+ const match = fileContentRegex.exec(part);
1430
+ if (match) {
1431
+ const filePathSpecInContent = match[1]; // This is a resolved pathSpec
1432
+ const fileActualContent = match[2].trim();
1433
+ processedQueryParts.push({
1434
+ text: `\nContent from @${filePathSpecInContent}:\n`,
1435
+ });
1436
+ processedQueryParts.push({ text: fileActualContent });
1437
+ } else {
1438
+ processedQueryParts.push({ text: part });
1439
+ }
1440
+ } else {
1441
+ // part is a Part object.
1442
+ processedQueryParts.push(part);
1443
+ }
1444
+ }
1445
+ } else {
1446
+ debugLogger.warn(
1447
+ 'read_many_files tool returned no content or empty content.',
1448
+ );
1449
+ }
1450
+ } catch (error: unknown) {
1451
+ await this.sendUpdate({
1452
+ sessionUpdate: 'tool_call_update',
1453
+ toolCallId: callId,
1454
+ status: 'failed',
1455
+ content: [
1456
+ {
1457
+ type: 'content',
1458
+ content: {
1459
+ type: 'text',
1460
+ text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
1461
+ },
1462
+ },
1463
+ ],
1464
+ kind: toAcpToolKind(readManyFilesTool.kind),
1465
+ });
1466
+
1467
+ throw error;
1468
+ }
1469
+ }
1470
+
1471
+ if (directContents.length > 0) {
1472
+ const hasReferenceStart = processedQueryParts.some(
1473
+ (p) =>
1474
+ 'text' in p &&
1475
+ typeof p.text === 'string' &&
1476
+ p.text.includes(REFERENCE_CONTENT_START),
1477
+ );
1478
+ if (!hasReferenceStart) {
1479
+ processedQueryParts.push({
1480
+ text: `\n${REFERENCE_CONTENT_START}`,
1481
+ });
1482
+ }
1483
+ for (const item of directContents) {
1484
+ processedQueryParts.push({
1485
+ text: `\nContent from @${item.spec}:\n`,
1486
+ });
1487
+ if (item.content) {
1488
+ processedQueryParts.push({ text: item.content });
1489
+ } else if (item.part) {
1490
+ processedQueryParts.push(item.part);
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ if (embeddedContext.length > 0) {
1496
+ processedQueryParts.push({
1497
+ text: '\n--- Content from referenced context ---',
1498
+ });
1499
+
1500
+ for (const contextPart of embeddedContext) {
1501
+ processedQueryParts.push({
1502
+ text: `\nContent from @${contextPart.uri}:\n`,
1503
+ });
1504
+ if ('text' in contextPart) {
1505
+ processedQueryParts.push({
1506
+ text: contextPart.text,
1507
+ });
1508
+ } else {
1509
+ processedQueryParts.push({
1510
+ inlineData: {
1511
+ mimeType: contextPart.mimeType ?? 'application/octet-stream',
1512
+ data: contextPart.blob,
1513
+ },
1514
+ });
1515
+ }
1516
+ }
1517
+ }
1518
+
1519
+ return processedQueryParts;
1520
+ }
1521
+
1522
+ debug(msg: string) {
1523
+ if (this.context.config.getDebugMode()) {
1524
+ debugLogger.warn(msg);
1525
+ }
1526
+ }
1527
+ }
packages/cli/src/acp/acpSessionManager.test.ts ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ describe,
9
+ it,
10
+ expect,
11
+ vi,
12
+ beforeEach,
13
+ afterEach,
14
+ type Mock,
15
+ type Mocked,
16
+ } from 'vitest';
17
+ import { AcpSessionManager } from './acpSessionManager.js';
18
+ import type * as acp from '@agentclientprotocol/sdk';
19
+ import {
20
+ AuthType,
21
+ type Config,
22
+ GEMINI_MODEL_ALIAS_AUTO,
23
+ type MessageBus,
24
+ type Storage,
25
+ } from '@google/gemini-cli-core';
26
+ import type { LoadedSettings } from '../config/settings.js';
27
+ import { loadCliConfig, type CliArgs } from '../config/config.js';
28
+ import { loadSettings } from '../config/settings.js';
29
+
30
+ vi.mock('../config/config.js', () => ({
31
+ loadCliConfig: vi.fn(),
32
+ }));
33
+
34
+ vi.mock('../config/settings.js', async (importOriginal) => {
35
+ const actual = await importOriginal<typeof import('../config/settings.js')>();
36
+ return {
37
+ ...actual,
38
+ loadSettings: vi.fn(),
39
+ };
40
+ });
41
+
42
+ const startAutoMemoryIfEnabledMock = vi.fn();
43
+ vi.mock('../utils/autoMemory.js', () => ({
44
+ startAutoMemoryIfEnabled: (config: Config) =>
45
+ startAutoMemoryIfEnabledMock(config),
46
+ }));
47
+
48
+ describe('AcpSessionManager', () => {
49
+ let mockConfig: Mocked<Config>;
50
+ let mockSettings: Mocked<LoadedSettings>;
51
+ let mockArgv: CliArgs;
52
+ let mockConnection: Mocked<acp.AgentSideConnection>;
53
+ let manager: AcpSessionManager;
54
+
55
+ beforeEach(() => {
56
+ mockConfig = {
57
+ refreshAuth: vi.fn(),
58
+ initialize: vi.fn(),
59
+ waitForMcpInit: vi.fn(),
60
+ getFileSystemService: vi.fn(),
61
+ setFileSystemService: vi.fn(),
62
+ getContentGeneratorConfig: vi.fn(),
63
+ getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
64
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
65
+ getGeminiClient: vi.fn().mockReturnValue({
66
+ startChat: vi.fn().mockResolvedValue({}),
67
+ }),
68
+ getMessageBus: vi.fn().mockReturnValue({
69
+ publish: vi.fn(),
70
+ subscribe: vi.fn(),
71
+ unsubscribe: vi.fn(),
72
+ }),
73
+ getApprovalMode: vi.fn().mockReturnValue('default'),
74
+ isPlanEnabled: vi.fn().mockReturnValue(true),
75
+ getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
76
+ getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
77
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
78
+ getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
79
+ validatePathAccess: vi.fn().mockReturnValue(null),
80
+ getWorkspaceContext: vi.fn().mockReturnValue({
81
+ addReadOnlyPath: vi.fn(),
82
+ getDirectories: vi.fn().mockReturnValue(['/tmp']),
83
+ }),
84
+ getPolicyEngine: vi.fn().mockReturnValue({
85
+ addRule: vi.fn(),
86
+ }),
87
+ messageBus: {
88
+ publish: vi.fn(),
89
+ subscribe: vi.fn(),
90
+ unsubscribe: vi.fn(),
91
+ } as unknown as MessageBus,
92
+ storage: {
93
+ getWorkspaceAutoSavedPolicyPath: vi.fn(),
94
+ getAutoSavedPolicyPath: vi.fn(),
95
+ } as unknown as Storage,
96
+
97
+ get config() {
98
+ return this;
99
+ },
100
+ } as unknown as Mocked<Config>;
101
+ mockSettings = {
102
+ merged: {
103
+ security: { auth: { selectedType: 'login_with_google' } },
104
+ mcpServers: {},
105
+ },
106
+ setValue: vi.fn(),
107
+ } as unknown as Mocked<LoadedSettings>;
108
+ mockArgv = {} as unknown as CliArgs;
109
+ mockConnection = {
110
+ sessionUpdate: vi.fn(),
111
+ requestPermission: vi.fn(),
112
+ } as unknown as Mocked<acp.AgentSideConnection>;
113
+
114
+ (loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
115
+ (loadSettings as unknown as Mock).mockImplementation(() => ({
116
+ merged: {
117
+ security: {
118
+ auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
119
+ enablePermanentToolApproval: true,
120
+ },
121
+ mcpServers: {},
122
+ },
123
+ setValue: vi.fn(),
124
+ }));
125
+
126
+ manager = new AcpSessionManager(mockSettings, mockArgv, mockConnection);
127
+ vi.mock('node:crypto', () => ({
128
+ randomUUID: () => 'test-session-id',
129
+ }));
130
+ });
131
+
132
+ afterEach(() => {
133
+ vi.restoreAllMocks();
134
+ });
135
+
136
+ it('should create a new session', async () => {
137
+ vi.useFakeTimers();
138
+ mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
139
+ apiKey: 'test-key',
140
+ });
141
+ const response = await manager.newSession(
142
+ {
143
+ cwd: '/tmp',
144
+ mcpServers: [],
145
+ },
146
+ {},
147
+ );
148
+
149
+ expect(response.sessionId).toBe('test-session-id');
150
+ expect(loadCliConfig).toHaveBeenCalled();
151
+ expect(mockConfig.initialize).toHaveBeenCalled();
152
+ expect(mockConfig.getGeminiClient).toHaveBeenCalled();
153
+
154
+ // Verify deferred call (sendAvailableCommands)
155
+ await vi.runAllTimersAsync();
156
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
157
+ expect.objectContaining({
158
+ update: expect.objectContaining({
159
+ sessionUpdate: 'available_commands_update',
160
+ }),
161
+ }),
162
+ );
163
+ vi.useRealTimers();
164
+ });
165
+
166
+ it('should return modes without plan mode when plan is disabled', async () => {
167
+ mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
168
+ apiKey: 'test-key',
169
+ });
170
+ mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false);
171
+ mockConfig.getApprovalMode = vi.fn().mockReturnValue('default');
172
+
173
+ const response = await manager.newSession(
174
+ {
175
+ cwd: '/tmp',
176
+ mcpServers: [],
177
+ },
178
+ {},
179
+ );
180
+
181
+ expect(response.modes).toEqual({
182
+ availableModes: [
183
+ { id: 'default', name: 'Default', description: 'Prompts for approval' },
184
+ {
185
+ id: 'autoEdit',
186
+ name: 'Auto Edit',
187
+ description: 'Auto-approves edit tools',
188
+ },
189
+ { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
190
+ ],
191
+ currentModeId: 'default',
192
+ });
193
+ });
194
+
195
+ it('should include preview models when user has access', async () => {
196
+ mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
197
+ apiKey: 'test-key',
198
+ });
199
+ mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
200
+ mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
201
+
202
+ const response = await manager.newSession(
203
+ {
204
+ cwd: '/tmp',
205
+ mcpServers: [],
206
+ },
207
+ {},
208
+ );
209
+
210
+ expect(response.models?.availableModels).toEqual(
211
+ expect.arrayContaining([
212
+ expect.objectContaining({
213
+ modelId: GEMINI_MODEL_ALIAS_AUTO,
214
+ name: expect.stringContaining('Auto'),
215
+ }),
216
+ ]),
217
+ );
218
+ });
219
+
220
+ it('should NOT include retired preview models (none) in available models', async () => {
221
+ mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
222
+ apiKey: 'test-key',
223
+ });
224
+ mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
225
+ mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
226
+
227
+ const response = await manager.newSession(
228
+ {
229
+ cwd: '/tmp',
230
+ mcpServers: [],
231
+ },
232
+ {},
233
+ );
234
+
235
+ const modelIds =
236
+ response.models?.availableModels?.map((m) => m.modelId) ?? [];
237
+ expect(modelIds).not.toContain('none');
238
+ });
239
+
240
+ it('should return modes with plan mode when plan is enabled', async () => {
241
+ mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
242
+ apiKey: 'test-key',
243
+ });
244
+ mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true);
245
+ mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan');
246
+
247
+ const response = await manager.newSession(
248
+ {
249
+ cwd: '/tmp',
250
+ mcpServers: [],
251
+ },
252
+ {},
253
+ );
254
+
255
+ expect(response.modes).toEqual({
256
+ availableModes: [
257
+ { id: 'default', name: 'Default', description: 'Prompts for approval' },
258
+ {
259
+ id: 'autoEdit',
260
+ name: 'Auto Edit',
261
+ description: 'Auto-approves edit tools',
262
+ },
263
+ { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
264
+ { id: 'plan', name: 'Plan', description: 'Read-only mode' },
265
+ ],
266
+ currentModeId: 'plan',
267
+ });
268
+ });
269
+
270
+ it('should fail session creation if Gemini API key is missing', async () => {
271
+ (loadSettings as unknown as Mock).mockImplementation(() => ({
272
+ merged: {
273
+ security: { auth: { selectedType: AuthType.USE_GEMINI } },
274
+ mcpServers: {},
275
+ },
276
+ setValue: vi.fn(),
277
+ }));
278
+ mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
279
+ apiKey: undefined,
280
+ });
281
+
282
+ await expect(
283
+ manager.newSession(
284
+ {
285
+ cwd: '/tmp',
286
+ mcpServers: [],
287
+ },
288
+ {},
289
+ ),
290
+ ).rejects.toMatchObject({
291
+ message: 'Gemini API key is missing or not configured.',
292
+ });
293
+ });
294
+
295
+ it('should create a new session with mcp servers', async () => {
296
+ mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
297
+ apiKey: 'test-key',
298
+ });
299
+ const mcpServers = [
300
+ {
301
+ name: 'test-server',
302
+ command: 'node',
303
+ args: ['server.js'],
304
+ env: [{ name: 'KEY', value: 'VALUE' }],
305
+ },
306
+ ];
307
+
308
+ await manager.newSession(
309
+ {
310
+ cwd: '/tmp',
311
+ mcpServers,
312
+ },
313
+ {},
314
+ );
315
+
316
+ expect(loadCliConfig).toHaveBeenCalledWith(
317
+ expect.objectContaining({
318
+ mcpServers: expect.objectContaining({
319
+ 'test-server': expect.objectContaining({
320
+ command: 'node',
321
+ args: ['server.js'],
322
+ env: { KEY: 'VALUE' },
323
+ }),
324
+ }),
325
+ }),
326
+ 'test-session-id',
327
+ mockArgv,
328
+ { cwd: '/tmp' },
329
+ );
330
+ });
331
+
332
+ it('should handle authentication failure gracefully', async () => {
333
+ mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed'));
334
+
335
+ await expect(
336
+ manager.newSession(
337
+ {
338
+ cwd: '/tmp',
339
+ mcpServers: [],
340
+ },
341
+ {},
342
+ ),
343
+ ).rejects.toMatchObject({
344
+ message: 'Auth failed',
345
+ });
346
+ });
347
+
348
+ it('should initialize file system service if client supports it', async () => {
349
+ mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
350
+ apiKey: 'test-key',
351
+ });
352
+ manager.setClientCapabilities({
353
+ fs: { readTextFile: true, writeTextFile: true },
354
+ });
355
+
356
+ await manager.newSession(
357
+ {
358
+ cwd: '/tmp',
359
+ mcpServers: [],
360
+ },
361
+ {},
362
+ );
363
+
364
+ expect(mockConfig.setFileSystemService).toHaveBeenCalled();
365
+ });
366
+
367
+ it('should start auto memory for new ACP sessions', async () => {
368
+ mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
369
+ apiKey: 'test-key',
370
+ });
371
+
372
+ await manager.newSession(
373
+ {
374
+ cwd: '/tmp',
375
+ mcpServers: [],
376
+ },
377
+ {},
378
+ );
379
+
380
+ expect(startAutoMemoryIfEnabledMock).toHaveBeenCalledWith(mockConfig);
381
+ });
382
+ });
packages/cli/src/acp/acpSessionManager.ts ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ type Config,
9
+ AuthType,
10
+ MCPServerConfig,
11
+ debugLogger,
12
+ startupProfiler,
13
+ convertSessionToClientHistory,
14
+ createPolicyUpdater,
15
+ } from '@google/gemini-cli-core';
16
+ import * as acp from '@agentclientprotocol/sdk';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { loadSettings, type LoadedSettings } from '../config/settings.js';
19
+ import { SessionSelector } from '../utils/sessionUtils.js';
20
+ import { Session } from './acpSession.js';
21
+ import { AcpFileSystemService } from './acpFileSystemService.js';
22
+ import { getAcpErrorMessage } from './acpErrors.js';
23
+ import { buildAvailableModels, buildAvailableModes } from './acpUtils.js';
24
+ import { loadCliConfig, type CliArgs } from '../config/config.js';
25
+ import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
26
+
27
+ export interface AuthDetails {
28
+ apiKey?: string;
29
+ baseUrl?: string;
30
+ customHeaders?: Record<string, string>;
31
+ }
32
+
33
+ export class AcpSessionManager {
34
+ private sessions: Map<string, Session> = new Map();
35
+ private clientCapabilities: acp.ClientCapabilities | undefined;
36
+
37
+ constructor(
38
+ private settings: LoadedSettings,
39
+ private argv: CliArgs,
40
+ private connection: acp.AgentSideConnection,
41
+ ) {}
42
+
43
+ setClientCapabilities(capabilities: acp.ClientCapabilities) {
44
+ this.clientCapabilities = capabilities;
45
+ }
46
+
47
+ getSession(sessionId: string): Session | undefined {
48
+ return this.sessions.get(sessionId);
49
+ }
50
+
51
+ dispose(): void {
52
+ for (const session of this.sessions.values()) {
53
+ session.dispose();
54
+ }
55
+ this.sessions.clear();
56
+ }
57
+
58
+ async newSession(
59
+ { cwd, mcpServers }: acp.NewSessionRequest,
60
+ authDetails: AuthDetails,
61
+ ): Promise<acp.NewSessionResponse> {
62
+ const sessionId = randomUUID();
63
+ const loadedSettings = loadSettings(cwd);
64
+ const config = await this.newSessionConfig(
65
+ sessionId,
66
+ cwd,
67
+ mcpServers,
68
+ loadedSettings,
69
+ );
70
+
71
+ const authType =
72
+ loadedSettings.merged.security.auth.selectedType ||
73
+ (authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
74
+ ? AuthType.GATEWAY
75
+ : AuthType.USE_GEMINI);
76
+
77
+ let isAuthenticated = false;
78
+ let authErrorMessage = '';
79
+ try {
80
+ await config.refreshAuth(
81
+ authType,
82
+ authDetails.apiKey,
83
+ authDetails.baseUrl,
84
+ authDetails.customHeaders,
85
+ );
86
+ isAuthenticated = true;
87
+
88
+ // Extra validation for Gemini API key
89
+ const contentGeneratorConfig = config.getContentGeneratorConfig();
90
+ if (
91
+ authType === AuthType.USE_GEMINI &&
92
+ (!contentGeneratorConfig || !contentGeneratorConfig.apiKey)
93
+ ) {
94
+ isAuthenticated = false;
95
+ authErrorMessage = 'Gemini API key is missing or not configured.';
96
+ }
97
+ } catch (e) {
98
+ isAuthenticated = false;
99
+ authErrorMessage = getAcpErrorMessage(e);
100
+ debugLogger.error(
101
+ `Authentication failed: ${e instanceof Error ? e.stack : e}`,
102
+ );
103
+ }
104
+
105
+ if (!isAuthenticated) {
106
+ throw new acp.RequestError(
107
+ -32000,
108
+ authErrorMessage || 'Authentication required.',
109
+ );
110
+ }
111
+
112
+ if (this.clientCapabilities?.fs) {
113
+ const acpFileSystemService = new AcpFileSystemService(
114
+ this.connection,
115
+ sessionId,
116
+ this.clientCapabilities.fs,
117
+ config.getFileSystemService(),
118
+ cwd,
119
+ );
120
+ config.setFileSystemService(acpFileSystemService);
121
+ }
122
+
123
+ await config.initialize();
124
+ startupProfiler.flush(config);
125
+ startAutoMemoryIfEnabled(config);
126
+
127
+ const geminiClient = config.getGeminiClient();
128
+
129
+ const chat = await geminiClient.startChat();
130
+
131
+ const session = new Session(
132
+ sessionId,
133
+ chat,
134
+ config,
135
+ this.connection,
136
+ this.settings,
137
+ );
138
+ this.sessions.set(sessionId, session);
139
+
140
+ setTimeout(() => {
141
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
142
+ session.sendAvailableCommands();
143
+ }, 0);
144
+
145
+ const { availableModels, currentModelId } = buildAvailableModels(
146
+ config,
147
+ loadedSettings,
148
+ );
149
+
150
+ const response = {
151
+ sessionId,
152
+ modes: {
153
+ availableModes: buildAvailableModes(config.isPlanEnabled()),
154
+ currentModeId: config.getApprovalMode(),
155
+ },
156
+ models: {
157
+ availableModels,
158
+ currentModelId,
159
+ },
160
+ };
161
+ return response;
162
+ }
163
+
164
+ async loadSession(
165
+ { sessionId, cwd, mcpServers }: acp.LoadSessionRequest,
166
+ authDetails: AuthDetails,
167
+ ): Promise<acp.LoadSessionResponse> {
168
+ const config = await this.initializeSessionConfig(
169
+ sessionId,
170
+ cwd,
171
+ mcpServers,
172
+ authDetails,
173
+ );
174
+
175
+ const sessionSelector = new SessionSelector(config.storage);
176
+
177
+ const { sessionData, sessionPath } =
178
+ await sessionSelector.resolveSession(sessionId);
179
+
180
+ const clientHistory = convertSessionToClientHistory(sessionData.messages);
181
+
182
+ const geminiClient = config.getGeminiClient();
183
+ await geminiClient.initialize();
184
+ await geminiClient.resumeChat(clientHistory, {
185
+ conversation: sessionData,
186
+ filePath: sessionPath,
187
+ });
188
+
189
+ const session = new Session(
190
+ sessionId,
191
+ geminiClient.getChat(),
192
+ config,
193
+ this.connection,
194
+ this.settings,
195
+ );
196
+
197
+ const existingSession = this.sessions.get(sessionId);
198
+ if (existingSession) {
199
+ existingSession.dispose();
200
+ }
201
+
202
+ this.sessions.set(sessionId, session);
203
+
204
+ // Stream history back to client
205
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
206
+ session.streamHistory(sessionData.messages);
207
+
208
+ setTimeout(() => {
209
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
210
+ session.sendAvailableCommands();
211
+ }, 0);
212
+
213
+ const { availableModels, currentModelId } = buildAvailableModels(
214
+ config,
215
+ this.settings,
216
+ );
217
+
218
+ const response = {
219
+ modes: {
220
+ availableModes: buildAvailableModes(config.isPlanEnabled()),
221
+ currentModeId: config.getApprovalMode(),
222
+ },
223
+ models: {
224
+ availableModels,
225
+ currentModelId,
226
+ },
227
+ };
228
+ return response;
229
+ }
230
+
231
+ private async initializeSessionConfig(
232
+ sessionId: string,
233
+ cwd: string,
234
+ mcpServers: acp.McpServer[],
235
+ authDetails: AuthDetails,
236
+ ): Promise<Config> {
237
+ const selectedAuthType =
238
+ this.settings.merged.security.auth.selectedType ||
239
+ (authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
240
+ ? AuthType.GATEWAY
241
+ : undefined);
242
+
243
+ if (!selectedAuthType) {
244
+ throw acp.RequestError.authRequired();
245
+ }
246
+
247
+ // 1. Create config WITHOUT initializing it (no MCP servers started yet)
248
+ const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
249
+
250
+ // 2. Authenticate BEFORE initializing configuration or starting MCP servers.
251
+ // This satisfies the security requirement to verify the user before executing
252
+ // potentially unsafe server definitions.
253
+ try {
254
+ await config.refreshAuth(
255
+ selectedAuthType,
256
+ authDetails.apiKey,
257
+ authDetails.baseUrl,
258
+ authDetails.customHeaders,
259
+ );
260
+ } catch (e) {
261
+ debugLogger.error(`Authentication failed: ${e}`);
262
+ throw acp.RequestError.authRequired();
263
+ }
264
+
265
+ // 3. Set the ACP FileSystemService (if supported) before config initialization
266
+ if (this.clientCapabilities?.fs) {
267
+ const acpFileSystemService = new AcpFileSystemService(
268
+ this.connection,
269
+ sessionId,
270
+ this.clientCapabilities.fs,
271
+ config.getFileSystemService(),
272
+ cwd,
273
+ );
274
+ config.setFileSystemService(acpFileSystemService);
275
+ }
276
+
277
+ // 4. Now that we are authenticated, it is safe to initialize the config
278
+ // which starts the MCP servers and other heavy resources.
279
+ await config.initialize();
280
+ startupProfiler.flush(config);
281
+ startAutoMemoryIfEnabled(config);
282
+
283
+ return config;
284
+ }
285
+
286
+ async newSessionConfig(
287
+ sessionId: string,
288
+ cwd: string,
289
+ mcpServers: acp.McpServer[],
290
+ loadedSettings?: LoadedSettings,
291
+ ): Promise<Config> {
292
+ const currentSettings = loadedSettings || this.settings;
293
+ const mergedMcpServers = { ...currentSettings.merged.mcpServers };
294
+
295
+ for (const server of mcpServers) {
296
+ if (
297
+ 'type' in server &&
298
+ (server.type === 'sse' || server.type === 'http')
299
+ ) {
300
+ // HTTP or SSE MCP server
301
+ const headers = Object.fromEntries(
302
+ server.headers.map(({ name, value }) => [name, value]),
303
+ );
304
+ mergedMcpServers[server.name] = new MCPServerConfig(
305
+ undefined, // command
306
+ undefined, // args
307
+ undefined, // env
308
+ undefined, // cwd
309
+ server.type === 'sse' ? server.url : undefined, // url (sse)
310
+ server.type === 'http' ? server.url : undefined, // httpUrl
311
+ headers,
312
+ );
313
+ } else if ('command' in server) {
314
+ // Stdio MCP server
315
+ const env: Record<string, string> = {};
316
+ for (const { name: envName, value } of server.env) {
317
+ env[envName] = value;
318
+ }
319
+ mergedMcpServers[server.name] = new MCPServerConfig(
320
+ server.command,
321
+ server.args,
322
+ env,
323
+ cwd,
324
+ );
325
+ }
326
+ }
327
+
328
+ const settings = {
329
+ ...currentSettings.merged,
330
+ mcpServers: mergedMcpServers,
331
+ };
332
+
333
+ const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
334
+
335
+ createPolicyUpdater(
336
+ config.getPolicyEngine(),
337
+ config.messageBus,
338
+ config.storage,
339
+ );
340
+
341
+ return config;
342
+ }
343
+ }
packages/cli/src/acp/acpStdioTransport.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { type Config, createWorkingStdio } from '@google/gemini-cli-core';
8
+ import { runExitCleanup } from '../utils/cleanup.js';
9
+ import * as acp from '@agentclientprotocol/sdk';
10
+ import { Readable, Writable } from 'node:stream';
11
+ import type { LoadedSettings } from '../config/settings.js';
12
+ import type { CliArgs } from '../config/config.js';
13
+ import { GeminiAgent } from './acpRpcDispatcher.js';
14
+
15
+ export async function runAcpClient(
16
+ config: Config,
17
+ settings: LoadedSettings,
18
+ argv: CliArgs,
19
+ ) {
20
+ const { stdout: workingStdout } = createWorkingStdio();
21
+ const stdout = Writable.toWeb(workingStdout) as WritableStream;
22
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
23
+ const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
24
+
25
+ const stream = acp.ndJsonStream(stdout, stdin);
26
+ const connection = new acp.AgentSideConnection(
27
+ (connection) => new GeminiAgent(config, settings, argv, connection),
28
+ stream,
29
+ );
30
+
31
+ // SIGTERM/SIGINT handlers (in sdk.ts) don't fire when stdin closes.
32
+ // We must explicitly await the connection close to flush telemetry.
33
+ // Use finally() to ensure cleanup runs even on stream errors.
34
+ await connection.closed.finally(runExitCleanup);
35
+ }
packages/cli/src/acp/acpUtils.ts ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ type Config,
9
+ type ToolResult,
10
+ type ToolCallConfirmationDetails,
11
+ Kind,
12
+ ApprovalMode,
13
+ GEMINI_MODEL_ALIAS_AUTO,
14
+ DEFAULT_GEMINI_MODEL,
15
+ DEFAULT_GEMINI_FLASH_MODEL,
16
+ DEFAULT_GEMINI_FLASH_LITE_MODEL,
17
+ PREVIEW_GEMINI_3_1_MODEL,
18
+ PREVIEW_GEMINI_MODEL,
19
+ PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
20
+ PREVIEW_GEMINI_FLASH_MODEL,
21
+ PREVIEW_GEMINI_FLASH_LITE_MODEL,
22
+ getDisplayString,
23
+ AuthType,
24
+ ToolConfirmationOutcome,
25
+ getAutoModelDescription,
26
+ } from '@google/gemini-cli-core';
27
+ import type * as acp from '@agentclientprotocol/sdk';
28
+ import { z } from 'zod';
29
+ import type { LoadedSettings } from '../config/settings.js';
30
+
31
+ export function hasMeta(
32
+ obj: unknown,
33
+ ): obj is { _meta?: Record<string, unknown> } {
34
+ return typeof obj === 'object' && obj !== null && '_meta' in obj;
35
+ }
36
+
37
+ export const RequestPermissionResponseSchema = z.object({
38
+ outcome: z.discriminatedUnion('outcome', [
39
+ z.object({ outcome: z.literal('cancelled') }),
40
+ z.object({
41
+ outcome: z.literal('selected'),
42
+ optionId: z.string(),
43
+ }),
44
+ ]),
45
+ });
46
+
47
+ export function toToolCallContent(
48
+ toolResult: ToolResult,
49
+ ): acp.ToolCallContent | null {
50
+ if (toolResult.error?.message) {
51
+ throw new Error(toolResult.error.message);
52
+ }
53
+
54
+ if (toolResult.returnDisplay) {
55
+ if (typeof toolResult.returnDisplay === 'string') {
56
+ return {
57
+ type: 'content',
58
+ content: { type: 'text', text: toolResult.returnDisplay },
59
+ };
60
+ } else {
61
+ if ('fileName' in toolResult.returnDisplay) {
62
+ return {
63
+ type: 'diff',
64
+ path:
65
+ toolResult.returnDisplay.filePath ??
66
+ toolResult.returnDisplay.fileName,
67
+ oldText: toolResult.returnDisplay.originalContent,
68
+ newText: toolResult.returnDisplay.newContent,
69
+ _meta: {
70
+ kind: !toolResult.returnDisplay.originalContent
71
+ ? 'add'
72
+ : toolResult.returnDisplay.newContent === ''
73
+ ? 'delete'
74
+ : 'modify',
75
+ },
76
+ };
77
+ }
78
+ return null;
79
+ }
80
+ } else {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ const basicPermissionOptions = [
86
+ {
87
+ optionId: ToolConfirmationOutcome.ProceedOnce,
88
+ name: 'Allow',
89
+ kind: 'allow_once',
90
+ },
91
+ {
92
+ optionId: ToolConfirmationOutcome.Cancel,
93
+ name: 'Reject',
94
+ kind: 'reject_once',
95
+ },
96
+ ] as const;
97
+
98
+ export function toPermissionOptions(
99
+ confirmation: ToolCallConfirmationDetails,
100
+ config: Config,
101
+ enablePermanentToolApproval: boolean = false,
102
+ ): acp.PermissionOption[] {
103
+ const disableAlwaysAllow = config.getDisableAlwaysAllow();
104
+ const options: acp.PermissionOption[] = [];
105
+
106
+ if (!disableAlwaysAllow) {
107
+ switch (confirmation.type) {
108
+ case 'edit':
109
+ options.push({
110
+ optionId: ToolConfirmationOutcome.ProceedAlways,
111
+ name: 'Allow for this session',
112
+ kind: 'allow_always',
113
+ });
114
+ if (enablePermanentToolApproval) {
115
+ options.push({
116
+ optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
117
+ name: 'Allow for this file in all future sessions',
118
+ kind: 'allow_always',
119
+ });
120
+ }
121
+ break;
122
+ case 'exec':
123
+ options.push({
124
+ optionId: ToolConfirmationOutcome.ProceedAlways,
125
+ name: 'Allow for this session',
126
+ kind: 'allow_always',
127
+ });
128
+ if (enablePermanentToolApproval) {
129
+ options.push({
130
+ optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
131
+ name: 'Allow this command for all future sessions',
132
+ kind: 'allow_always',
133
+ });
134
+ }
135
+ break;
136
+ case 'mcp':
137
+ options.push(
138
+ {
139
+ optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
140
+ name: 'Allow all server tools for this session',
141
+ kind: 'allow_always',
142
+ },
143
+ {
144
+ optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
145
+ name: 'Allow tool for this session',
146
+ kind: 'allow_always',
147
+ },
148
+ );
149
+ if (enablePermanentToolApproval) {
150
+ options.push({
151
+ optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
152
+ name: 'Allow tool for all future sessions',
153
+ kind: 'allow_always',
154
+ });
155
+ }
156
+ break;
157
+ case 'info':
158
+ options.push({
159
+ optionId: ToolConfirmationOutcome.ProceedAlways,
160
+ name: 'Allow for this session',
161
+ kind: 'allow_always',
162
+ });
163
+ if (enablePermanentToolApproval) {
164
+ options.push({
165
+ optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
166
+ name: 'Allow for all future sessions',
167
+ kind: 'allow_always',
168
+ });
169
+ }
170
+ break;
171
+ case 'ask_user':
172
+ case 'exit_plan_mode':
173
+ // askuser and exit_plan_mode don't need "always allow" options
174
+ break;
175
+ default:
176
+ // No "always allow" options for other types
177
+ break;
178
+ }
179
+ }
180
+
181
+ options.push(...basicPermissionOptions);
182
+
183
+ // Exhaustive check
184
+ switch (confirmation.type) {
185
+ case 'edit':
186
+ case 'exec':
187
+ case 'mcp':
188
+ case 'info':
189
+ case 'ask_user':
190
+ case 'exit_plan_mode':
191
+ case 'sandbox_expansion':
192
+ break;
193
+ default: {
194
+ const unreachable: never = confirmation;
195
+ throw new Error(`Unexpected: ${unreachable}`);
196
+ }
197
+ }
198
+
199
+ return options;
200
+ }
201
+
202
+ export function toAcpToolKind(kind: Kind): acp.ToolKind {
203
+ switch (kind) {
204
+ case Kind.Read:
205
+ case Kind.Edit:
206
+ case Kind.Execute:
207
+ case Kind.Search:
208
+ case Kind.Delete:
209
+ case Kind.Move:
210
+ case Kind.Think:
211
+ case Kind.Fetch:
212
+ case Kind.SwitchMode:
213
+ case Kind.Other:
214
+ return kind as acp.ToolKind;
215
+ case Kind.Agent:
216
+ return 'think';
217
+ case Kind.Plan:
218
+ case Kind.Communicate:
219
+ default:
220
+ return 'other';
221
+ }
222
+ }
223
+
224
+ export function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
225
+ const modes: acp.SessionMode[] = [
226
+ {
227
+ id: ApprovalMode.DEFAULT,
228
+ name: 'Default',
229
+ description: 'Prompts for approval',
230
+ },
231
+ {
232
+ id: ApprovalMode.AUTO_EDIT,
233
+ name: 'Auto Edit',
234
+ description: 'Auto-approves edit tools',
235
+ },
236
+ {
237
+ id: ApprovalMode.YOLO,
238
+ name: 'YOLO',
239
+ description: 'Auto-approves all tools',
240
+ },
241
+ ];
242
+
243
+ if (isPlanEnabled) {
244
+ modes.push({
245
+ id: ApprovalMode.PLAN,
246
+ name: 'Plan',
247
+ description: 'Read-only mode',
248
+ });
249
+ }
250
+
251
+ return modes;
252
+ }
253
+
254
+ export function buildAvailableModels(
255
+ config: Config,
256
+ settings: LoadedSettings,
257
+ ): {
258
+ availableModels: Array<{
259
+ modelId: string;
260
+ name: string;
261
+ description?: string;
262
+ }>;
263
+ currentModelId: string;
264
+ } {
265
+ const preferredModel = config.getModel() || GEMINI_MODEL_ALIAS_AUTO;
266
+ const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
267
+ const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
268
+ const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
269
+ const selectedAuthType = settings.merged.security.auth.selectedType;
270
+ const useCustomToolModel =
271
+ useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
272
+
273
+ // --- DYNAMIC PATH ---
274
+ if (
275
+ config.getExperimentalDynamicModelConfiguration?.() === true &&
276
+ config.getModelConfigService
277
+ ) {
278
+ const options = config.getModelConfigService().getAvailableModelOptions({
279
+ useGemini3_1: useGemini31,
280
+ useGemini3_5Flash,
281
+ useCustomTools: useCustomToolModel,
282
+ hasAccessToPreview: shouldShowPreviewModels,
283
+ });
284
+
285
+ return {
286
+ availableModels: options,
287
+ currentModelId: preferredModel,
288
+ };
289
+ }
290
+
291
+ // --- LEGACY PATH ---
292
+ const mainOptions = [
293
+ {
294
+ value: GEMINI_MODEL_ALIAS_AUTO,
295
+ title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
296
+ description: getAutoModelDescription(
297
+ shouldShowPreviewModels,
298
+ useGemini31,
299
+ useGemini3_5Flash,
300
+ ),
301
+ },
302
+ ];
303
+
304
+ const manualOptions = [
305
+ {
306
+ value: DEFAULT_GEMINI_MODEL,
307
+ title: getDisplayString(DEFAULT_GEMINI_MODEL),
308
+ },
309
+ {
310
+ value: DEFAULT_GEMINI_FLASH_MODEL,
311
+ title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
312
+ },
313
+ {
314
+ value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
315
+ title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
316
+ },
317
+ ];
318
+
319
+ if (shouldShowPreviewModels) {
320
+ const previewProModel = useGemini31
321
+ ? PREVIEW_GEMINI_3_1_MODEL
322
+ : PREVIEW_GEMINI_MODEL;
323
+
324
+ const previewProValue = useCustomToolModel
325
+ ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
326
+ : previewProModel;
327
+
328
+ const previewOptions = [
329
+ {
330
+ value: previewProValue,
331
+ title: getDisplayString(previewProModel),
332
+ },
333
+ {
334
+ value: PREVIEW_GEMINI_FLASH_MODEL,
335
+ title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
336
+ },
337
+ ];
338
+
339
+ if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
340
+ previewOptions.push({
341
+ value: PREVIEW_GEMINI_FLASH_LITE_MODEL,
342
+ title: getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL),
343
+ });
344
+ }
345
+
346
+ manualOptions.unshift(...previewOptions);
347
+ }
348
+
349
+ const scaleOptions = (
350
+ options: Array<{ value: string; title: string; description?: string }>,
351
+ ) =>
352
+ options.map((o) => ({
353
+ modelId: o.value,
354
+ name: o.title,
355
+ description: o.description,
356
+ }));
357
+
358
+ return {
359
+ availableModels: [
360
+ ...scaleOptions(mainOptions),
361
+ ...scaleOptions(manualOptions),
362
+ ],
363
+ currentModelId: preferredModel,
364
+ };
365
+ }
packages/cli/src/acp/commands/about.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ IdeClient,
9
+ UserAccountManager,
10
+ getVersion,
11
+ } from '@google/gemini-cli-core';
12
+ import type {
13
+ Command,
14
+ CommandContext,
15
+ CommandExecutionResponse,
16
+ } from './types.js';
17
+ import process from 'node:process';
18
+
19
+ export class AboutCommand implements Command {
20
+ readonly name = 'about';
21
+ readonly description = 'Show version and environment info';
22
+
23
+ async execute(
24
+ context: CommandContext,
25
+ _args: string[] = [],
26
+ ): Promise<CommandExecutionResponse> {
27
+ const osVersion = process.platform;
28
+ let sandboxEnv = 'no sandbox';
29
+ if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
30
+ sandboxEnv = process.env['SANDBOX'];
31
+ } else if (process.env['SANDBOX'] === 'sandbox-exec') {
32
+ sandboxEnv = `sandbox-exec (${
33
+ process.env['SEATBELT_PROFILE'] || 'unknown'
34
+ })`;
35
+ }
36
+ const modelVersion = context.agentContext.config.getModel() || 'Unknown';
37
+ const cliVersion = await getVersion();
38
+ const selectedAuthType =
39
+ context.settings.merged?.security?.auth?.selectedType ?? '';
40
+ const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
41
+ const ideClient = await getIdeClientName(context);
42
+
43
+ const userAccountManager = new UserAccountManager();
44
+ const cachedAccount = userAccountManager.getCachedGoogleAccount();
45
+ const userEmail = cachedAccount ?? 'Unknown';
46
+
47
+ const tier = context.agentContext.config.getUserTierName() || 'Unknown';
48
+
49
+ const info = [
50
+ `- Version: ${cliVersion}`,
51
+ `- OS: ${osVersion}`,
52
+ `- Sandbox: ${sandboxEnv}`,
53
+ `- Model: ${modelVersion}`,
54
+ `- Auth Type: ${selectedAuthType}`,
55
+ `- GCP Project: ${gcpProject}`,
56
+ `- IDE Client: ${ideClient}`,
57
+ `- User Email: ${userEmail}`,
58
+ `- Tier: ${tier}`,
59
+ ].join('\n');
60
+
61
+ return {
62
+ name: this.name,
63
+ data: `Gemini CLI Info:\n${info}`,
64
+ };
65
+ }
66
+ }
67
+
68
+ async function getIdeClientName(context: CommandContext) {
69
+ if (!context.agentContext.config.getIdeMode()) {
70
+ return '';
71
+ }
72
+ const ideClient = await IdeClient.getInstance();
73
+ return ideClient?.getDetectedIdeDisplayName() ?? '';
74
+ }
packages/cli/src/acp/commands/commandRegistry.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { debugLogger } from '@google/gemini-cli-core';
8
+ import type { Command } from './types.js';
9
+
10
+ export class CommandRegistry {
11
+ private readonly commands = new Map<string, Command>();
12
+
13
+ register(command: Command) {
14
+ if (this.commands.has(command.name)) {
15
+ debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
16
+ return;
17
+ }
18
+
19
+ this.commands.set(command.name, command);
20
+
21
+ for (const subCommand of command.subCommands ?? []) {
22
+ this.register(subCommand);
23
+ }
24
+ }
25
+
26
+ get(commandName: string): Command | undefined {
27
+ return this.commands.get(commandName);
28
+ }
29
+
30
+ getAllCommands(): Command[] {
31
+ return [...this.commands.values()];
32
+ }
33
+ }
packages/cli/src/acp/commands/extensions.test.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import type { CommandContext } from './types.js';
9
+ import {
10
+ DisableExtensionCommand,
11
+ UninstallExtensionCommand,
12
+ } from './extensions.js';
13
+ import { ExtensionManager } from '../../config/extension-manager.js';
14
+
15
+ const mockGetErrorMessage = vi.hoisted(() => vi.fn());
16
+
17
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
18
+ const actual =
19
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
20
+ return {
21
+ ...actual,
22
+ getErrorMessage: mockGetErrorMessage,
23
+ };
24
+ });
25
+
26
+ vi.mock('../../config/extension-manager.js', () => {
27
+ class MockExtensionManager {
28
+ disableExtension = vi.fn(async () => undefined);
29
+ uninstallExtension = vi.fn(async () => undefined);
30
+ getExtensions = vi.fn(() => []);
31
+ }
32
+
33
+ return {
34
+ ExtensionManager: MockExtensionManager,
35
+ inferInstallMetadata: vi.fn(),
36
+ };
37
+ });
38
+
39
+ type TestExtensionManager = InstanceType<typeof ExtensionManager> & {
40
+ disableExtension: ReturnType<typeof vi.fn>;
41
+ uninstallExtension: ReturnType<typeof vi.fn>;
42
+ getExtensions: ReturnType<typeof vi.fn>;
43
+ };
44
+
45
+ function createExtensionManager(): TestExtensionManager {
46
+ return new ExtensionManager({} as never) as TestExtensionManager;
47
+ }
48
+
49
+ function createContext(extensionLoader: unknown): CommandContext {
50
+ return {
51
+ agentContext: {
52
+ config: {
53
+ getExtensionLoader: vi.fn().mockReturnValue(extensionLoader),
54
+ },
55
+ } as unknown as CommandContext['agentContext'],
56
+ settings: {} as CommandContext['settings'],
57
+ sendMessage: vi.fn().mockResolvedValue(undefined),
58
+ };
59
+ }
60
+
61
+ describe('ACP extensions error paths', () => {
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ mockGetErrorMessage.mockImplementation((error: unknown) =>
65
+ error instanceof Error ? error.message : String(error),
66
+ );
67
+ });
68
+
69
+ it('returns error when disabling fails', async () => {
70
+ const command = new DisableExtensionCommand();
71
+ const extensionManager = createExtensionManager();
72
+ extensionManager.disableExtension.mockRejectedValue(
73
+ new Error('Extension not found.'),
74
+ );
75
+ const context = createContext(extensionManager);
76
+
77
+ const result = await command.execute(context, ['missing-ext']);
78
+
79
+ expect(result).toEqual({
80
+ name: 'extensions disable',
81
+ data: 'Failed to disable "missing-ext": Extension not found.',
82
+ });
83
+ });
84
+
85
+ it('returns error when uninstalling a non-existent extension', async () => {
86
+ const command = new UninstallExtensionCommand();
87
+ const extensionManager = createExtensionManager();
88
+ extensionManager.uninstallExtension.mockRejectedValue(
89
+ new Error('Extension not found.'),
90
+ );
91
+ const context = createContext(extensionManager);
92
+
93
+ const result = await command.execute(context, ['non-existent-ext']);
94
+
95
+ expect(result).toEqual({
96
+ name: 'extensions uninstall',
97
+ data: 'Failed to uninstall extension "non-existent-ext": Extension not found.',
98
+ });
99
+ });
100
+ });
packages/cli/src/acp/commands/extensions.ts ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ listExtensions,
9
+ type Config,
10
+ getErrorMessage,
11
+ } from '@google/gemini-cli-core';
12
+ import { SettingScope } from '../../config/settings.js';
13
+ import {
14
+ ExtensionManager,
15
+ inferInstallMetadata,
16
+ } from '../../config/extension-manager.js';
17
+ import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
18
+ import { stat } from 'node:fs/promises';
19
+ import type {
20
+ Command,
21
+ CommandContext,
22
+ CommandExecutionResponse,
23
+ } from './types.js';
24
+
25
+ export class ExtensionsCommand implements Command {
26
+ readonly name = 'extensions';
27
+ readonly description = 'Manage extensions.';
28
+ readonly subCommands = [
29
+ new ListExtensionsCommand(),
30
+ new ExploreExtensionsCommand(),
31
+ new EnableExtensionCommand(),
32
+ new DisableExtensionCommand(),
33
+ new InstallExtensionCommand(),
34
+ new LinkExtensionCommand(),
35
+ new UninstallExtensionCommand(),
36
+ new RestartExtensionCommand(),
37
+ new UpdateExtensionCommand(),
38
+ ];
39
+
40
+ async execute(
41
+ context: CommandContext,
42
+ _: string[],
43
+ ): Promise<CommandExecutionResponse> {
44
+ return new ListExtensionsCommand().execute(context, _);
45
+ }
46
+ }
47
+
48
+ export class ListExtensionsCommand implements Command {
49
+ readonly name = 'extensions list';
50
+ readonly description = 'Lists all installed extensions.';
51
+
52
+ async execute(
53
+ context: CommandContext,
54
+ _: string[],
55
+ ): Promise<CommandExecutionResponse> {
56
+ const extensions = listExtensions(context.agentContext.config);
57
+ const data = extensions.length ? extensions : 'No extensions installed.';
58
+
59
+ return { name: this.name, data };
60
+ }
61
+ }
62
+
63
+ export class ExploreExtensionsCommand implements Command {
64
+ readonly name = 'extensions explore';
65
+ readonly description = 'Explore available extensions.';
66
+
67
+ async execute(
68
+ _context: CommandContext,
69
+ _: string[],
70
+ ): Promise<CommandExecutionResponse> {
71
+ const extensionsUrl = 'https://geminicli.com/extensions/';
72
+ return {
73
+ name: this.name,
74
+ data: `View or install available extensions at ${extensionsUrl}`,
75
+ };
76
+ }
77
+ }
78
+
79
+ function getEnableDisableContext(
80
+ config: Config,
81
+ args: string[],
82
+ invocationName: string,
83
+ ) {
84
+ const extensionManager = config.getExtensionLoader();
85
+ if (!(extensionManager instanceof ExtensionManager)) {
86
+ return {
87
+ error: `Cannot ${invocationName} extensions in this environment.`,
88
+ };
89
+ }
90
+
91
+ if (args.length === 0) {
92
+ return {
93
+ error: `Usage: /extensions ${invocationName} <extension> [--scope=<user|workspace|session>]`,
94
+ };
95
+ }
96
+
97
+ let scope = SettingScope.User;
98
+ if (args.includes('--scope=workspace') || args.includes('workspace')) {
99
+ scope = SettingScope.Workspace;
100
+ } else if (args.includes('--scope=session') || args.includes('session')) {
101
+ scope = SettingScope.Session;
102
+ }
103
+
104
+ const name = args.filter(
105
+ (a) =>
106
+ !a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
107
+ )[0];
108
+
109
+ let names: string[] = [];
110
+ if (name === '--all') {
111
+ let extensions = extensionManager.getExtensions();
112
+ if (invocationName === 'enable') {
113
+ extensions = extensions.filter((ext) => !ext.isActive);
114
+ }
115
+ if (invocationName === 'disable') {
116
+ extensions = extensions.filter((ext) => ext.isActive);
117
+ }
118
+ names = extensions.map((ext) => ext.name);
119
+ } else if (name) {
120
+ names = [name];
121
+ } else {
122
+ return { error: 'No extension name provided.' };
123
+ }
124
+
125
+ return { extensionManager, names, scope };
126
+ }
127
+
128
+ export class EnableExtensionCommand implements Command {
129
+ readonly name = 'extensions enable';
130
+ readonly description = 'Enable an extension.';
131
+
132
+ async execute(
133
+ context: CommandContext,
134
+ args: string[],
135
+ ): Promise<CommandExecutionResponse> {
136
+ const enableContext = getEnableDisableContext(
137
+ context.agentContext.config,
138
+ args,
139
+ 'enable',
140
+ );
141
+ if ('error' in enableContext) {
142
+ return { name: this.name, data: enableContext.error };
143
+ }
144
+
145
+ const { names, scope, extensionManager } = enableContext;
146
+ const output: string[] = [];
147
+
148
+ for (const name of names) {
149
+ try {
150
+ await extensionManager.enableExtension(name, scope);
151
+ output.push(`Extension "${name}" enabled for scope "${scope}".`);
152
+
153
+ const extension = extensionManager
154
+ .getExtensions()
155
+ .find((e) => e.name === name);
156
+
157
+ if (extension?.mcpServers) {
158
+ const mcpEnablementManager = McpServerEnablementManager.getInstance();
159
+ const mcpClientManager =
160
+ context.agentContext.config.getMcpClientManager();
161
+ const enabledServers = await mcpEnablementManager.autoEnableServers(
162
+ Object.keys(extension.mcpServers),
163
+ );
164
+
165
+ if (mcpClientManager && enabledServers.length > 0) {
166
+ const restartPromises = enabledServers.map((serverName) =>
167
+ mcpClientManager.restartServer(serverName).catch((error) => {
168
+ output.push(
169
+ `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
170
+ );
171
+ }),
172
+ );
173
+ await Promise.all(restartPromises);
174
+ output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
175
+ }
176
+ }
177
+ } catch (e) {
178
+ output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
179
+ }
180
+ }
181
+
182
+ return { name: this.name, data: output.join('\n') || 'No action taken.' };
183
+ }
184
+ }
185
+
186
+ export class DisableExtensionCommand implements Command {
187
+ readonly name = 'extensions disable';
188
+ readonly description = 'Disable an extension.';
189
+
190
+ async execute(
191
+ context: CommandContext,
192
+ args: string[],
193
+ ): Promise<CommandExecutionResponse> {
194
+ const enableContext = getEnableDisableContext(
195
+ context.agentContext.config,
196
+ args,
197
+ 'disable',
198
+ );
199
+ if ('error' in enableContext) {
200
+ return { name: this.name, data: enableContext.error };
201
+ }
202
+
203
+ const { names, scope, extensionManager } = enableContext;
204
+ const output: string[] = [];
205
+
206
+ for (const name of names) {
207
+ try {
208
+ await extensionManager.disableExtension(name, scope);
209
+ output.push(`Extension "${name}" disabled for scope "${scope}".`);
210
+ } catch (e) {
211
+ output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
212
+ }
213
+ }
214
+
215
+ return { name: this.name, data: output.join('\n') || 'No action taken.' };
216
+ }
217
+ }
218
+
219
+ export class InstallExtensionCommand implements Command {
220
+ readonly name = 'extensions install';
221
+ readonly description = 'Install an extension from a git repo or local path.';
222
+
223
+ async execute(
224
+ context: CommandContext,
225
+ args: string[],
226
+ ): Promise<CommandExecutionResponse> {
227
+ const extensionLoader = context.agentContext.config.getExtensionLoader();
228
+ if (!(extensionLoader instanceof ExtensionManager)) {
229
+ return {
230
+ name: this.name,
231
+ data: 'Cannot install extensions in this environment.',
232
+ };
233
+ }
234
+
235
+ const source = args.join(' ').trim();
236
+ if (!source) {
237
+ return { name: this.name, data: `Usage: /extensions install <source>` };
238
+ }
239
+
240
+ if (/[;&|`'"]/.test(source)) {
241
+ return {
242
+ name: this.name,
243
+ data: `Invalid source: contains disallowed characters.`,
244
+ };
245
+ }
246
+
247
+ try {
248
+ const installMetadata = await inferInstallMetadata(source);
249
+ const extension =
250
+ await extensionLoader.installOrUpdateExtension(installMetadata);
251
+ return {
252
+ name: this.name,
253
+ data: `Extension "${extension.name}" installed successfully.`,
254
+ };
255
+ } catch (error) {
256
+ return {
257
+ name: this.name,
258
+ data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
259
+ };
260
+ }
261
+ }
262
+ }
263
+
264
+ export class LinkExtensionCommand implements Command {
265
+ readonly name = 'extensions link';
266
+ readonly description = 'Link an extension from a local path.';
267
+
268
+ async execute(
269
+ context: CommandContext,
270
+ args: string[],
271
+ ): Promise<CommandExecutionResponse> {
272
+ const extensionLoader = context.agentContext.config.getExtensionLoader();
273
+ if (!(extensionLoader instanceof ExtensionManager)) {
274
+ return {
275
+ name: this.name,
276
+ data: 'Cannot link extensions in this environment.',
277
+ };
278
+ }
279
+
280
+ const sourceFilepath = args.join(' ').trim();
281
+ if (!sourceFilepath) {
282
+ return { name: this.name, data: `Usage: /extensions link <source>` };
283
+ }
284
+
285
+ try {
286
+ await stat(sourceFilepath);
287
+ } catch {
288
+ return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
289
+ }
290
+
291
+ try {
292
+ const extension = await extensionLoader.installOrUpdateExtension({
293
+ source: sourceFilepath,
294
+ type: 'link',
295
+ });
296
+ return {
297
+ name: this.name,
298
+ data: `Extension "${extension.name}" linked successfully.`,
299
+ };
300
+ } catch (error) {
301
+ return {
302
+ name: this.name,
303
+ data: `Failed to link extension: ${getErrorMessage(error)}`,
304
+ };
305
+ }
306
+ }
307
+ }
308
+
309
+ export class UninstallExtensionCommand implements Command {
310
+ readonly name = 'extensions uninstall';
311
+ readonly description = 'Uninstall an extension.';
312
+
313
+ async execute(
314
+ context: CommandContext,
315
+ args: string[],
316
+ ): Promise<CommandExecutionResponse> {
317
+ const extensionLoader = context.agentContext.config.getExtensionLoader();
318
+ if (!(extensionLoader instanceof ExtensionManager)) {
319
+ return {
320
+ name: this.name,
321
+ data: 'Cannot uninstall extensions in this environment.',
322
+ };
323
+ }
324
+
325
+ const all = args.includes('--all');
326
+ const names = args.filter((a) => !a.startsWith('--')).map((a) => a.trim());
327
+
328
+ if (!all && names.length === 0) {
329
+ return {
330
+ name: this.name,
331
+ data: `Usage: /extensions uninstall <extension-names...>|--all`,
332
+ };
333
+ }
334
+
335
+ let namesToUninstall: string[] = [];
336
+ if (all) {
337
+ namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name);
338
+ } else {
339
+ namesToUninstall = names;
340
+ }
341
+
342
+ if (namesToUninstall.length === 0) {
343
+ return {
344
+ name: this.name,
345
+ data: all ? 'No extensions installed.' : 'No extension name provided.',
346
+ };
347
+ }
348
+
349
+ const output: string[] = [];
350
+ for (const extensionName of namesToUninstall) {
351
+ try {
352
+ await extensionLoader.uninstallExtension(extensionName, false);
353
+ output.push(`Extension "${extensionName}" uninstalled successfully.`);
354
+ } catch (error) {
355
+ output.push(
356
+ `Failed to uninstall extension "${extensionName}": ${getErrorMessage(error)}`,
357
+ );
358
+ }
359
+ }
360
+
361
+ return { name: this.name, data: output.join('\n') };
362
+ }
363
+ }
364
+
365
+ export class RestartExtensionCommand implements Command {
366
+ readonly name = 'extensions restart';
367
+ readonly description = 'Restart an extension.';
368
+
369
+ async execute(
370
+ context: CommandContext,
371
+ args: string[],
372
+ ): Promise<CommandExecutionResponse> {
373
+ const extensionLoader = context.agentContext.config.getExtensionLoader();
374
+ if (!(extensionLoader instanceof ExtensionManager)) {
375
+ return { name: this.name, data: 'Cannot restart extensions.' };
376
+ }
377
+
378
+ const all = args.includes('--all');
379
+ const names = all ? null : args.filter((a) => !!a);
380
+
381
+ if (!all && names?.length === 0) {
382
+ return {
383
+ name: this.name,
384
+ data: 'Usage: /extensions restart <extension-names>|--all',
385
+ };
386
+ }
387
+
388
+ let extensionsToRestart = extensionLoader
389
+ .getExtensions()
390
+ .filter((e) => e.isActive);
391
+ if (names) {
392
+ extensionsToRestart = extensionsToRestart.filter((e) =>
393
+ names.includes(e.name),
394
+ );
395
+ }
396
+
397
+ if (extensionsToRestart.length === 0) {
398
+ return {
399
+ name: this.name,
400
+ data: 'No active extensions matched the request.',
401
+ };
402
+ }
403
+
404
+ const output: string[] = [];
405
+ for (const extension of extensionsToRestart) {
406
+ try {
407
+ await extensionLoader.restartExtension(extension);
408
+ output.push(`Restarted "${extension.name}".`);
409
+ } catch (e) {
410
+ output.push(
411
+ `Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
412
+ );
413
+ }
414
+ }
415
+
416
+ return { name: this.name, data: output.join('\n') };
417
+ }
418
+ }
419
+
420
+ export class UpdateExtensionCommand implements Command {
421
+ readonly name = 'extensions update';
422
+ readonly description = 'Update an extension.';
423
+
424
+ async execute(
425
+ context: CommandContext,
426
+ args: string[],
427
+ ): Promise<CommandExecutionResponse> {
428
+ const extensionLoader = context.agentContext.config.getExtensionLoader();
429
+ if (!(extensionLoader instanceof ExtensionManager)) {
430
+ return { name: this.name, data: 'Cannot update extensions.' };
431
+ }
432
+
433
+ const all = args.includes('--all');
434
+ const names = all ? null : args.filter((a) => !!a);
435
+
436
+ if (!all && names?.length === 0) {
437
+ return {
438
+ name: this.name,
439
+ data: 'Usage: /extensions update <extension-names>|--all',
440
+ };
441
+ }
442
+
443
+ return {
444
+ name: this.name,
445
+ data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
446
+ };
447
+ }
448
+ }
packages/cli/src/acp/commands/help.test.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { HelpCommand } from './help.js';
9
+ import { CommandRegistry } from './commandRegistry.js';
10
+ import type { Command, CommandContext } from './types.js';
11
+
12
+ describe('HelpCommand', () => {
13
+ it('returns formatted help text with sorted commands', async () => {
14
+ const registry = new CommandRegistry();
15
+
16
+ const cmdB: Command = {
17
+ name: 'bravo',
18
+ description: 'Bravo command',
19
+ execute: async () => ({ name: 'bravo', data: '' }),
20
+ };
21
+
22
+ const cmdA: Command = {
23
+ name: 'alpha',
24
+ description: 'Alpha command',
25
+ execute: async () => ({ name: 'alpha', data: '' }),
26
+ };
27
+
28
+ registry.register(cmdB);
29
+ registry.register(cmdA);
30
+
31
+ const helpCommand = new HelpCommand(registry);
32
+
33
+ const context = {} as CommandContext;
34
+
35
+ const response = await helpCommand.execute(context, []);
36
+
37
+ expect(response.name).toBe('help');
38
+
39
+ const data = response.data as string;
40
+
41
+ expect(data).toContain('Gemini CLI Help:');
42
+ expect(data).toContain('### Basics');
43
+ expect(data).toContain('### Commands');
44
+
45
+ const lines = data.split('\n');
46
+ const alphaIndex = lines.findIndex((l) => l.includes('/alpha'));
47
+ const bravoIndex = lines.findIndex((l) => l.includes('/bravo'));
48
+
49
+ expect(alphaIndex).toBeLessThan(bravoIndex);
50
+ expect(alphaIndex).not.toBe(-1);
51
+ expect(bravoIndex).not.toBe(-1);
52
+ });
53
+ });
packages/cli/src/acp/commands/help.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type {
8
+ Command,
9
+ CommandContext,
10
+ CommandExecutionResponse,
11
+ } from './types.js';
12
+ import type { CommandRegistry } from './commandRegistry.js';
13
+
14
+ export class HelpCommand implements Command {
15
+ readonly name = 'help';
16
+ readonly description = 'Show available commands';
17
+
18
+ constructor(private registry: CommandRegistry) {}
19
+
20
+ async execute(
21
+ _context: CommandContext,
22
+ _args: string[] = [],
23
+ ): Promise<CommandExecutionResponse> {
24
+ const commands = this.registry
25
+ .getAllCommands()
26
+ .sort((a, b) => a.name.localeCompare(b.name));
27
+
28
+ const lines: string[] = [];
29
+
30
+ lines.push('Gemini CLI Help:');
31
+ lines.push('');
32
+ lines.push('### Basics');
33
+ lines.push(
34
+ '- **Add context**: Use `@` to specify files for context (e.g., `@src/myFile.ts`) to target specific files or folders.',
35
+ );
36
+ lines.push('');
37
+
38
+ lines.push('### Commands');
39
+ for (const cmd of commands) {
40
+ if (cmd.description) {
41
+ lines.push(`- **/${cmd.name}** - ${cmd.description}`);
42
+ }
43
+ }
44
+
45
+ return {
46
+ name: this.name,
47
+ data: lines.join('\n'),
48
+ };
49
+ }
50
+ }
packages/cli/src/acp/commands/init.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { performInit } from '@google/gemini-cli-core';
10
+ import type {
11
+ Command,
12
+ CommandContext,
13
+ CommandExecutionResponse,
14
+ } from './types.js';
15
+
16
+ export class InitCommand implements Command {
17
+ name = 'init';
18
+ description = 'Analyzes the project and creates a tailored GEMINI.md file';
19
+ requiresWorkspace = true;
20
+
21
+ async execute(
22
+ context: CommandContext,
23
+ _args: string[] = [],
24
+ ): Promise<CommandExecutionResponse> {
25
+ const targetDir = context.agentContext.config.getTargetDir();
26
+ if (!targetDir) {
27
+ throw new Error('Command requires a workspace.');
28
+ }
29
+
30
+ const geminiMdPath = path.join(targetDir, 'GEMINI.md');
31
+ const result = performInit(fs.existsSync(geminiMdPath));
32
+
33
+ switch (result.type) {
34
+ case 'message':
35
+ return {
36
+ name: this.name,
37
+ data: result,
38
+ };
39
+ case 'submit_prompt':
40
+ fs.writeFileSync(geminiMdPath, '', 'utf8');
41
+
42
+ if (typeof result.content !== 'string') {
43
+ throw new Error('Init command content must be a string.');
44
+ }
45
+
46
+ // Inform the user since we can't trigger the UI-based interactive agent loop here directly.
47
+ // We output the prompt text they can use to re-trigger the generation manually,
48
+ // or just seed the GEMINI.md file as we've done above.
49
+ return {
50
+ name: this.name,
51
+ data: {
52
+ type: 'message',
53
+ messageType: 'info',
54
+ content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`,
55
+ },
56
+ };
57
+
58
+ default:
59
+ throw new Error('Unknown result type from performInit');
60
+ }
61
+ }
62
+ }
packages/cli/src/acp/commands/memory.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ listInboxMemoryPatches,
9
+ listInboxSkills,
10
+ listInboxPatches,
11
+ listMemoryFiles,
12
+ refreshMemory,
13
+ showMemory,
14
+ } from '@google/gemini-cli-core';
15
+ import type {
16
+ Command,
17
+ CommandContext,
18
+ CommandExecutionResponse,
19
+ } from './types.js';
20
+
21
+ export class MemoryCommand implements Command {
22
+ readonly name = 'memory';
23
+ readonly description = 'Manage memory.';
24
+ readonly subCommands = [
25
+ new ShowMemoryCommand(),
26
+ new RefreshMemoryCommand(),
27
+ new ListMemoryCommand(),
28
+ new InboxMemoryCommand(),
29
+ ];
30
+ readonly requiresWorkspace = true;
31
+
32
+ async execute(
33
+ context: CommandContext,
34
+ _: string[],
35
+ ): Promise<CommandExecutionResponse> {
36
+ return new ShowMemoryCommand().execute(context, _);
37
+ }
38
+ }
39
+
40
+ export class ShowMemoryCommand implements Command {
41
+ readonly name = 'memory show';
42
+ readonly description = 'Shows the current memory contents.';
43
+
44
+ async execute(
45
+ context: CommandContext,
46
+ _: string[],
47
+ ): Promise<CommandExecutionResponse> {
48
+ const result = showMemory(context.agentContext.config);
49
+ return { name: this.name, data: result.content };
50
+ }
51
+ }
52
+
53
+ export class RefreshMemoryCommand implements Command {
54
+ readonly name = 'memory refresh';
55
+ readonly aliases = ['memory reload'];
56
+ readonly description = 'Refreshes the memory from the source.';
57
+
58
+ async execute(
59
+ context: CommandContext,
60
+ _: string[],
61
+ ): Promise<CommandExecutionResponse> {
62
+ const result = await refreshMemory(context.agentContext.config);
63
+ return { name: this.name, data: result.content };
64
+ }
65
+ }
66
+
67
+ export class ListMemoryCommand implements Command {
68
+ readonly name = 'memory list';
69
+ readonly description = 'Lists the paths of the GEMINI.md files in use.';
70
+
71
+ async execute(
72
+ context: CommandContext,
73
+ _: string[],
74
+ ): Promise<CommandExecutionResponse> {
75
+ const result = listMemoryFiles(context.agentContext.config);
76
+ return { name: this.name, data: result.content };
77
+ }
78
+ }
79
+
80
+ export class InboxMemoryCommand implements Command {
81
+ readonly name = 'memory inbox';
82
+ readonly description =
83
+ 'Lists memory items extracted from past sessions that are pending review.';
84
+
85
+ async execute(
86
+ context: CommandContext,
87
+ _: string[],
88
+ ): Promise<CommandExecutionResponse> {
89
+ if (!context.agentContext.config.isAutoMemoryEnabled()) {
90
+ return {
91
+ name: this.name,
92
+ data: 'The memory inbox requires Auto Memory. Enable it with: experimental.autoMemory = true in settings.',
93
+ };
94
+ }
95
+
96
+ const [skills, patches, memoryPatches] = await Promise.all([
97
+ listInboxSkills(context.agentContext.config),
98
+ listInboxPatches(context.agentContext.config),
99
+ listInboxMemoryPatches(context.agentContext.config),
100
+ ]);
101
+
102
+ if (
103
+ skills.length === 0 &&
104
+ patches.length === 0 &&
105
+ memoryPatches.length === 0
106
+ ) {
107
+ return { name: this.name, data: 'No items in inbox.' };
108
+ }
109
+
110
+ const lines: string[] = [];
111
+ for (const s of skills) {
112
+ const date = s.extractedAt
113
+ ? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})`
114
+ : '';
115
+ lines.push(`- **${s.name}**: ${s.description}${date}`);
116
+ }
117
+ for (const p of patches) {
118
+ const targets = p.entries.map((e) => e.targetPath).join(', ');
119
+ const date = p.extractedAt
120
+ ? ` (extracted: ${new Date(p.extractedAt).toLocaleDateString()})`
121
+ : '';
122
+ lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
123
+ }
124
+ for (const memoryPatch of memoryPatches) {
125
+ const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
126
+ const date = memoryPatch.extractedAt
127
+ ? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
128
+ : '';
129
+ const sourceCount = memoryPatch.sourceFiles.length;
130
+ const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
131
+ lines.push(
132
+ `- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
133
+ );
134
+ }
135
+
136
+ const total = skills.length + patches.length + memoryPatches.length;
137
+ return {
138
+ name: this.name,
139
+ data: `Memory inbox (${total}):\n${lines.join('\n')}`,
140
+ };
141
+ }
142
+ }
packages/cli/src/acp/commands/restore.test.ts ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
9
+ import * as fs from 'node:fs/promises';
10
+ import {
11
+ getCheckpointInfoList,
12
+ getToolCallDataSchema,
13
+ isNodeError,
14
+ performRestore,
15
+ } from '@google/gemini-cli-core';
16
+ import type { CommandContext } from './types.js';
17
+ import type { Mock } from 'vitest';
18
+
19
+ vi.mock('node:fs/promises');
20
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
21
+ const actual =
22
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
23
+ return {
24
+ ...actual,
25
+ getCheckpointInfoList: vi.fn(),
26
+ getToolCallDataSchema: vi.fn(),
27
+ isNodeError: vi.fn(),
28
+ performRestore: vi.fn(),
29
+ };
30
+ });
31
+
32
+ describe('RestoreCommand', () => {
33
+ let context: CommandContext;
34
+ let restoreCommand: RestoreCommand;
35
+
36
+ beforeEach(() => {
37
+ vi.resetAllMocks();
38
+ restoreCommand = new RestoreCommand();
39
+ context = {
40
+ agentContext: {
41
+ config: {
42
+ getCheckpointingEnabled: vi.fn().mockReturnValue(true),
43
+ storage: {
44
+ getProjectTempCheckpointsDir: vi
45
+ .fn()
46
+ .mockReturnValue('/tmp/checkpoints'),
47
+ },
48
+ },
49
+ },
50
+ git: {},
51
+ sendMessage: vi.fn(),
52
+ } as unknown as CommandContext;
53
+ });
54
+
55
+ it('delegates to list behavior when invoked without args', async () => {
56
+ const listExecuteSpy = vi
57
+ .spyOn(ListCheckpointsCommand.prototype, 'execute')
58
+ .mockResolvedValue({
59
+ name: 'restore list',
60
+ data: 'list data',
61
+ });
62
+
63
+ const response = await restoreCommand.execute(context, []);
64
+
65
+ expect(listExecuteSpy).toHaveBeenCalledWith(context);
66
+ expect(response).toEqual({
67
+ name: 'restore list',
68
+ data: 'list data',
69
+ });
70
+ });
71
+
72
+ it('returns checkpointing-disabled message when disabled', async () => {
73
+ (
74
+ context.agentContext.config.getCheckpointingEnabled as Mock
75
+ ).mockReturnValue(false);
76
+
77
+ const response = await restoreCommand.execute(context, ['checkpoint1']);
78
+
79
+ expect(response.data).toContain('Checkpointing is not enabled');
80
+ });
81
+
82
+ it('returns file-not-found message for missing checkpoint', async () => {
83
+ const error = new Error('ENOENT');
84
+ (error as Error & { code: string }).code = 'ENOENT';
85
+ vi.mocked(fs.readFile).mockRejectedValue(error);
86
+ vi.mocked(isNodeError).mockReturnValue(true);
87
+
88
+ const response = await restoreCommand.execute(context, ['missing']);
89
+
90
+ expect(response.data).toBe('File not found: missing.json');
91
+ });
92
+
93
+ it('handles checkpoint filename already ending in .json', async () => {
94
+ const error = new Error('ENOENT');
95
+ (error as Error & { code: string }).code = 'ENOENT';
96
+ vi.mocked(fs.readFile).mockRejectedValue(error);
97
+ vi.mocked(isNodeError).mockReturnValue(true);
98
+
99
+ const response = await restoreCommand.execute(context, ['existing.json']);
100
+
101
+ expect(response.data).toBe('File not found: existing.json');
102
+ expect(fs.readFile).toHaveBeenCalledWith(
103
+ expect.stringContaining('existing.json'),
104
+ 'utf-8',
105
+ );
106
+ });
107
+
108
+ it('returns invalid/corrupt checkpoint message when schema parse fails', async () => {
109
+ vi.mocked(fs.readFile).mockResolvedValue('{"invalid": "data"}');
110
+ vi.mocked(getToolCallDataSchema).mockReturnValue({
111
+ safeParse: vi.fn().mockReturnValue({ success: false }),
112
+ } as unknown as ReturnType<typeof getToolCallDataSchema>);
113
+
114
+ const response = await restoreCommand.execute(context, ['invalid']);
115
+
116
+ expect(response.data).toBe('Checkpoint file is invalid or corrupted.');
117
+ });
118
+
119
+ it('formats streamed restore results correctly', async () => {
120
+ vi.mocked(fs.readFile).mockResolvedValue('{"valid": "data"}');
121
+ vi.mocked(getToolCallDataSchema).mockReturnValue({
122
+ safeParse: vi
123
+ .fn()
124
+ .mockReturnValue({ success: true, data: { some: 'data' } }),
125
+ } as unknown as ReturnType<typeof getToolCallDataSchema>);
126
+
127
+ async function* mockRestoreGenerator() {
128
+ yield { type: 'message', messageType: 'info', content: 'Restoring...' };
129
+ yield { type: 'load_history', clientHistory: [{}, {}] };
130
+ yield { type: 'other', some: 'other' };
131
+ }
132
+ vi.mocked(performRestore).mockReturnValue(
133
+ mockRestoreGenerator() as unknown as ReturnType<typeof performRestore>,
134
+ );
135
+
136
+ const response = await restoreCommand.execute(context, ['valid']);
137
+
138
+ expect(response.data).toContain('[INFO] Restoring...');
139
+ expect(response.data).toContain('Loaded history with 2 messages.');
140
+ expect(response.data).toContain(
141
+ 'Restored: {"type":"other","some":"other"}',
142
+ );
143
+ });
144
+
145
+ it('returns generic unexpected error message for non-ENOENT failures', async () => {
146
+ vi.mocked(fs.readFile).mockRejectedValue(new Error('Random error'));
147
+ vi.mocked(isNodeError).mockReturnValue(false);
148
+
149
+ const response = await restoreCommand.execute(context, ['error']);
150
+
151
+ expect(response.data).toContain(
152
+ 'An unexpected error occurred during restore: Error: Random error',
153
+ );
154
+ });
155
+ });
156
+
157
+ describe('ListCheckpointsCommand', () => {
158
+ let context: CommandContext;
159
+ let listCommand: ListCheckpointsCommand;
160
+ let mockReaddir: Mock<(path: string) => Promise<string[]>>;
161
+
162
+ beforeEach(() => {
163
+ vi.resetAllMocks();
164
+ listCommand = new ListCheckpointsCommand();
165
+ mockReaddir = vi.mocked(fs.readdir) as unknown as Mock<
166
+ (path: string) => Promise<string[]>
167
+ >;
168
+
169
+ context = {
170
+ agentContext: {
171
+ config: {
172
+ getCheckpointingEnabled: vi.fn().mockReturnValue(true),
173
+ storage: {
174
+ getProjectTempCheckpointsDir: vi
175
+ .fn()
176
+ .mockReturnValue('/tmp/checkpoints'),
177
+ },
178
+ },
179
+ },
180
+ } as unknown as CommandContext;
181
+ });
182
+
183
+ it('returns checkpointing-disabled message when disabled', async () => {
184
+ (
185
+ context.agentContext.config.getCheckpointingEnabled as Mock
186
+ ).mockReturnValue(false);
187
+
188
+ const response = await listCommand.execute(context);
189
+
190
+ expect(response.data).toContain('Checkpointing is not enabled');
191
+ });
192
+
193
+ it('returns "No checkpoints found." when no .json checkpoints exist', async () => {
194
+ mockReaddir.mockResolvedValue(['not-a-checkpoint.txt']);
195
+
196
+ const response = await listCommand.execute(context);
197
+
198
+ expect(response.data).toBe('No checkpoints found.');
199
+ });
200
+
201
+ it('ignores error when mkdir fails', async () => {
202
+ vi.mocked(fs.mkdir).mockRejectedValue(new Error('mkdir fail'));
203
+ mockReaddir.mockResolvedValue([]);
204
+
205
+ const response = await listCommand.execute(context);
206
+
207
+ expect(response.data).toBe('No checkpoints found.');
208
+ expect(fs.mkdir).toHaveBeenCalled();
209
+ });
210
+
211
+ it('formats checkpoint summary output from checkpoint metadata', async () => {
212
+ mockReaddir.mockResolvedValue(['cp1.json', 'cp2.json']);
213
+ vi.mocked(getCheckpointInfoList).mockReturnValue([
214
+ { messageId: 'id1', checkpoint: 'cp1' },
215
+ { messageId: 'id2', checkpoint: 'cp2' },
216
+ ]);
217
+
218
+ const response = await listCommand.execute(context);
219
+
220
+ expect(response.data).toContain('Available Checkpoints:');
221
+ // Note: The current implementation of ListCheckpointsCommand incorrectly accesses
222
+ // fileName, toolName, etc. which don't exist on CheckpointInfo, resulting in 'Unknown'.
223
+ expect(response.data).toContain('- **Unknown**: Unknown (Status: Unknown)');
224
+ });
225
+
226
+ it('handles empty checkpoint info list', async () => {
227
+ mockReaddir.mockResolvedValue(['some.json']);
228
+ vi.mocked(getCheckpointInfoList).mockReturnValue([]);
229
+
230
+ const response = await listCommand.execute(context);
231
+
232
+ expect(response.data).toBe('Available Checkpoints:\n');
233
+ });
234
+
235
+ it('returns generic unexpected error message on failures', async () => {
236
+ mockReaddir.mockRejectedValue(new Error('Readdir fail'));
237
+
238
+ const response = await listCommand.execute(context);
239
+
240
+ expect(response.data).toBe(
241
+ 'An unexpected error occurred while listing checkpoints.',
242
+ );
243
+ });
244
+ });
packages/cli/src/acp/commands/restore.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ getCheckpointInfoList,
9
+ getToolCallDataSchema,
10
+ isNodeError,
11
+ performRestore,
12
+ } from '@google/gemini-cli-core';
13
+ import * as fs from 'node:fs/promises';
14
+ import * as path from 'node:path';
15
+ import type {
16
+ Command,
17
+ CommandContext,
18
+ CommandExecutionResponse,
19
+ } from './types.js';
20
+
21
+ export class RestoreCommand implements Command {
22
+ readonly name = 'restore';
23
+ readonly description =
24
+ 'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
25
+ readonly requiresWorkspace = true;
26
+ readonly subCommands = [new ListCheckpointsCommand()];
27
+
28
+ async execute(
29
+ context: CommandContext,
30
+ args: string[],
31
+ ): Promise<CommandExecutionResponse> {
32
+ const { agentContext: agentContext, git: gitService } = context;
33
+ const { config } = agentContext;
34
+ const argsStr = args.join(' ');
35
+
36
+ try {
37
+ if (!argsStr) {
38
+ return await new ListCheckpointsCommand().execute(context);
39
+ }
40
+
41
+ if (!config.getCheckpointingEnabled()) {
42
+ return {
43
+ name: this.name,
44
+ data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
45
+ };
46
+ }
47
+
48
+ const selectedFile = argsStr.endsWith('.json')
49
+ ? argsStr
50
+ : `${argsStr}.json`;
51
+
52
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
53
+ const filePath = path.join(checkpointDir, selectedFile);
54
+
55
+ let data: string;
56
+ try {
57
+ data = await fs.readFile(filePath, 'utf-8');
58
+ } catch (error) {
59
+ if (isNodeError(error) && error.code === 'ENOENT') {
60
+ return {
61
+ name: this.name,
62
+ data: `File not found: ${selectedFile}`,
63
+ };
64
+ }
65
+ throw error;
66
+ }
67
+
68
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
69
+ const toolCallData = JSON.parse(data);
70
+ const ToolCallDataSchema = getToolCallDataSchema();
71
+ const parseResult = ToolCallDataSchema.safeParse(toolCallData);
72
+
73
+ if (!parseResult.success) {
74
+ return {
75
+ name: this.name,
76
+ data: 'Checkpoint file is invalid or corrupted.',
77
+ };
78
+ }
79
+
80
+ const restoreResultGenerator = performRestore(
81
+ parseResult.data,
82
+ gitService,
83
+ );
84
+
85
+ const restoreResult = [];
86
+ for await (const result of restoreResultGenerator) {
87
+ restoreResult.push(result);
88
+ }
89
+
90
+ // Format the result nicely since Zed just dumps data
91
+ const formattedResult = restoreResult
92
+ .map((r) => {
93
+ if (r.type === 'message') {
94
+ return `[${r.messageType.toUpperCase()}] ${r.content}`;
95
+ } else if (r.type === 'load_history') {
96
+ return `Loaded history with ${r.clientHistory.length} messages.`;
97
+ }
98
+ return `Restored: ${JSON.stringify(r)}`;
99
+ })
100
+ .join('\n');
101
+
102
+ return {
103
+ name: this.name,
104
+ data: formattedResult,
105
+ };
106
+ } catch (error) {
107
+ return {
108
+ name: this.name,
109
+ data: `An unexpected error occurred during restore: ${error}`,
110
+ };
111
+ }
112
+ }
113
+ }
114
+
115
+ export class ListCheckpointsCommand implements Command {
116
+ readonly name = 'restore list';
117
+ readonly description = 'Lists all available checkpoints.';
118
+
119
+ async execute(context: CommandContext): Promise<CommandExecutionResponse> {
120
+ const { config } = context.agentContext;
121
+
122
+ try {
123
+ if (!config.getCheckpointingEnabled()) {
124
+ return {
125
+ name: this.name,
126
+ data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
127
+ };
128
+ }
129
+
130
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
131
+ try {
132
+ await fs.mkdir(checkpointDir, { recursive: true });
133
+ } catch {
134
+ // Ignore
135
+ }
136
+
137
+ const files = await fs.readdir(checkpointDir);
138
+ const jsonFiles = files.filter((file) => file.endsWith('.json'));
139
+
140
+ if (jsonFiles.length === 0) {
141
+ return { name: this.name, data: 'No checkpoints found.' };
142
+ }
143
+
144
+ const checkpointFiles = new Map<string, string>();
145
+ for (const file of jsonFiles) {
146
+ const filePath = path.join(checkpointDir, file);
147
+ const data = await fs.readFile(filePath, 'utf-8');
148
+ checkpointFiles.set(file, data);
149
+ }
150
+
151
+ const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
152
+
153
+ const formatted = checkpointInfoList
154
+ .map((info) => {
155
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
156
+ const i = info as Record<string, any>;
157
+ const fileName = String(i['fileName'] || 'Unknown');
158
+ const toolName = String(i['toolName'] || 'Unknown');
159
+ const status = String(i['status'] || 'Unknown');
160
+ const timestamp = new Date(
161
+ Number(i['timestamp']) || 0,
162
+ ).toLocaleString();
163
+
164
+ return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
165
+ })
166
+ .join('\n');
167
+
168
+ return {
169
+ name: this.name,
170
+ data: `Available Checkpoints:\n${formatted}`,
171
+ };
172
+ } catch {
173
+ return {
174
+ name: this.name,
175
+ data: 'An unexpected error occurred while listing checkpoints.',
176
+ };
177
+ }
178
+ }
179
+ }
packages/cli/src/acp/commands/types.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { AgentLoopContext, GitService } from '@google/gemini-cli-core';
8
+ import type { LoadedSettings } from '../../config/settings.js';
9
+
10
+ export interface CommandContext {
11
+ agentContext: AgentLoopContext;
12
+ settings: LoadedSettings;
13
+ git?: GitService;
14
+ sendMessage: (text: string) => Promise<void>;
15
+ }
16
+
17
+ export interface CommandArgument {
18
+ readonly name: string;
19
+ readonly description: string;
20
+ readonly isRequired?: boolean;
21
+ }
22
+
23
+ export interface Command {
24
+ readonly name: string;
25
+ readonly aliases?: string[];
26
+ readonly description: string;
27
+ readonly arguments?: CommandArgument[];
28
+ readonly subCommands?: Command[];
29
+ readonly requiresWorkspace?: boolean;
30
+
31
+ execute(
32
+ context: CommandContext,
33
+ args: string[],
34
+ ): Promise<CommandExecutionResponse>;
35
+ }
36
+
37
+ export interface CommandExecutionResponse {
38
+ readonly name: string;
39
+ readonly data: unknown;
40
+ }
packages/cli/src/commands/extensions.test.tsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { extensionsCommand } from './extensions.js';
9
+
10
+ // Mock subcommands
11
+ vi.mock('./extensions/install.js', () => ({
12
+ installCommand: { command: 'install' },
13
+ }));
14
+ vi.mock('./extensions/uninstall.js', () => ({
15
+ uninstallCommand: { command: 'uninstall' },
16
+ }));
17
+ vi.mock('./extensions/list.js', () => ({ listCommand: { command: 'list' } }));
18
+ vi.mock('./extensions/update.js', () => ({
19
+ updateCommand: { command: 'update' },
20
+ }));
21
+ vi.mock('./extensions/disable.js', () => ({
22
+ disableCommand: { command: 'disable' },
23
+ }));
24
+ vi.mock('./extensions/enable.js', () => ({
25
+ enableCommand: { command: 'enable' },
26
+ }));
27
+ vi.mock('./extensions/link.js', () => ({ linkCommand: { command: 'link' } }));
28
+ vi.mock('./extensions/new.js', () => ({ newCommand: { command: 'new' } }));
29
+ vi.mock('./extensions/validate.js', () => ({
30
+ validateCommand: { command: 'validate' },
31
+ }));
32
+
33
+ // Mock gemini.js
34
+ vi.mock('../gemini.js', () => ({
35
+ initializeOutputListenersAndFlush: vi.fn(),
36
+ }));
37
+
38
+ describe('extensionsCommand', () => {
39
+ it('should have correct command and aliases', () => {
40
+ expect(extensionsCommand.command).toBe('extensions <command>');
41
+ expect(extensionsCommand.aliases).toEqual(['extension']);
42
+ expect(extensionsCommand.describe).toBe('Manage Gemini CLI extensions.');
43
+ });
44
+
45
+ it('should register all subcommands in builder', () => {
46
+ const mockYargs = {
47
+ middleware: vi.fn().mockReturnThis(),
48
+ command: vi.fn().mockReturnThis(),
49
+ demandCommand: vi.fn().mockReturnThis(),
50
+ version: vi.fn().mockReturnThis(),
51
+ };
52
+
53
+ // @ts-expect-error - Mocking yargs
54
+ extensionsCommand.builder(mockYargs);
55
+
56
+ expect(mockYargs.middleware).toHaveBeenCalled();
57
+ expect(mockYargs.command).toHaveBeenCalledWith(
58
+ expect.objectContaining({ command: 'install' }),
59
+ );
60
+ expect(mockYargs.command).toHaveBeenCalledWith(
61
+ expect.objectContaining({ command: 'uninstall' }),
62
+ );
63
+ expect(mockYargs.command).toHaveBeenCalledWith(
64
+ expect.objectContaining({ command: 'list' }),
65
+ );
66
+ expect(mockYargs.command).toHaveBeenCalledWith(
67
+ expect.objectContaining({ command: 'update' }),
68
+ );
69
+ expect(mockYargs.command).toHaveBeenCalledWith(
70
+ expect.objectContaining({ command: 'disable' }),
71
+ );
72
+ expect(mockYargs.command).toHaveBeenCalledWith(
73
+ expect.objectContaining({ command: 'enable' }),
74
+ );
75
+ expect(mockYargs.command).toHaveBeenCalledWith(
76
+ expect.objectContaining({ command: 'link' }),
77
+ );
78
+ expect(mockYargs.command).toHaveBeenCalledWith(
79
+ expect.objectContaining({ command: 'new' }),
80
+ );
81
+ expect(mockYargs.command).toHaveBeenCalledWith(
82
+ expect.objectContaining({ command: 'validate' }),
83
+ );
84
+ expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
85
+ expect(mockYargs.version).toHaveBeenCalledWith(false);
86
+ });
87
+
88
+ it('should have a handler that does nothing', () => {
89
+ // @ts-expect-error - Handler doesn't take arguments in this case
90
+ expect(extensionsCommand.handler()).toBeUndefined();
91
+ });
92
+ });
packages/cli/src/commands/extensions.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { CommandModule } from 'yargs';
8
+ import { installCommand } from './extensions/install.js';
9
+ import { uninstallCommand } from './extensions/uninstall.js';
10
+ import { listCommand } from './extensions/list.js';
11
+ import { updateCommand } from './extensions/update.js';
12
+ import { disableCommand } from './extensions/disable.js';
13
+ import { enableCommand } from './extensions/enable.js';
14
+ import { linkCommand } from './extensions/link.js';
15
+ import { newCommand } from './extensions/new.js';
16
+ import { validateCommand } from './extensions/validate.js';
17
+ import { configureCommand } from './extensions/configure.js';
18
+ import { initializeOutputListenersAndFlush } from '../gemini.js';
19
+ import { defer } from '../deferred.js';
20
+
21
+ export const extensionsCommand: CommandModule = {
22
+ command: 'extensions <command>',
23
+ aliases: ['extension'],
24
+ describe: 'Manage Gemini CLI extensions.',
25
+ builder: (yargs) =>
26
+ yargs
27
+ .middleware((argv) => {
28
+ initializeOutputListenersAndFlush();
29
+ argv['isCommand'] = true;
30
+ })
31
+ .command(defer(installCommand, 'extensions'))
32
+ .command(defer(uninstallCommand, 'extensions'))
33
+ .command(defer(listCommand, 'extensions'))
34
+ .command(defer(updateCommand, 'extensions'))
35
+ .command(defer(disableCommand, 'extensions'))
36
+ .command(defer(enableCommand, 'extensions'))
37
+ .command(defer(linkCommand, 'extensions'))
38
+ .command(defer(newCommand, 'extensions'))
39
+ .command(defer(validateCommand, 'extensions'))
40
+ .command(defer(configureCommand, 'extensions'))
41
+ .demandCommand(1, 'You need at least one command before continuing.')
42
+ .version(false),
43
+ handler: () => {
44
+ // This handler is not called when a subcommand is provided.
45
+ // Yargs will show the help menu.
46
+ },
47
+ };
packages/cli/src/commands/extensions/configure.test.ts ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import {
7
+ describe,
8
+ it,
9
+ expect,
10
+ vi,
11
+ beforeEach,
12
+ afterEach,
13
+ type Mock,
14
+ } from 'vitest';
15
+ import { configureCommand } from './configure.js';
16
+ import yargs from 'yargs';
17
+ import { debugLogger } from '@google/gemini-cli-core';
18
+ import {
19
+ updateSetting,
20
+ getScopedEnvContents,
21
+ type ExtensionSetting,
22
+ } from '../../config/extensions/extensionSettings.js';
23
+ import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
24
+ import prompts from 'prompts';
25
+ import * as fs from 'node:fs';
26
+ import * as os from 'node:os';
27
+ import * as path from 'node:path';
28
+
29
+ const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
30
+ vi.hoisted(() => {
31
+ const extensionManager = {
32
+ loadExtensionConfig: vi.fn(),
33
+ getExtensions: vi.fn(),
34
+ loadExtensions: vi.fn(),
35
+ getSettings: vi.fn(),
36
+ };
37
+ return {
38
+ mockExtensionManager: extensionManager,
39
+ mockGetExtensionManager: vi.fn(),
40
+ mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }),
41
+ };
42
+ });
43
+
44
+ vi.mock('../../config/extension-manager.js', () => ({
45
+ ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager),
46
+ }));
47
+
48
+ vi.mock('../../config/extensions/extensionSettings.js', () => ({
49
+ updateSetting: vi.fn(),
50
+ promptForSetting: vi.fn(),
51
+ getScopedEnvContents: vi.fn(),
52
+ ExtensionSettingScope: {
53
+ USER: 'user',
54
+ WORKSPACE: 'workspace',
55
+ },
56
+ }));
57
+
58
+ vi.mock('../utils.js', () => ({
59
+ exitCli: vi.fn(),
60
+ }));
61
+
62
+ vi.mock('./utils.js', async (importOriginal) => {
63
+ const actual = await importOriginal<typeof import('./utils.js')>();
64
+ return {
65
+ ...actual,
66
+ getExtensionManager: mockGetExtensionManager,
67
+ };
68
+ });
69
+
70
+ vi.mock('prompts');
71
+
72
+ vi.mock('../../config/extensions/consent.js', () => ({
73
+ requestConsentNonInteractive: vi.fn(),
74
+ }));
75
+
76
+ import { ExtensionManager } from '../../config/extension-manager.js';
77
+
78
+ vi.mock('../../config/settings.js', () => ({
79
+ loadSettings: mockLoadSettings,
80
+ }));
81
+
82
+ describe('extensions configure command', () => {
83
+ let tempWorkspaceDir: string;
84
+
85
+ beforeEach(() => {
86
+ vi.spyOn(debugLogger, 'log');
87
+ vi.spyOn(debugLogger, 'error');
88
+ vi.clearAllMocks();
89
+
90
+ tempWorkspaceDir = fs.mkdtempSync(
91
+ path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
92
+ );
93
+ vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
94
+ // Default behaviors
95
+ mockLoadSettings.mockReturnValue({ merged: {} });
96
+ mockGetExtensionManager.mockResolvedValue(mockExtensionManager);
97
+ (ExtensionManager as unknown as Mock).mockImplementation(
98
+ () => mockExtensionManager,
99
+ );
100
+ });
101
+
102
+ afterEach(async () => {
103
+ await cleanupTmpDir(tempWorkspaceDir);
104
+ vi.restoreAllMocks();
105
+ });
106
+
107
+ const runCommand = async (command: string) => {
108
+ const parser = yargs().command(configureCommand).help(false).version(false);
109
+ await parser.parse(command);
110
+ };
111
+
112
+ const setupExtension = (
113
+ name: string,
114
+ settings: Array<Partial<ExtensionSetting>> = [],
115
+ id = 'test-id',
116
+ path = '/test/path',
117
+ ) => {
118
+ const extension = { name, path, id };
119
+
120
+ mockExtensionManager.getExtensions.mockReturnValue([extension]);
121
+ mockExtensionManager.loadExtensionConfig.mockResolvedValue({
122
+ name,
123
+ settings,
124
+ });
125
+ return extension;
126
+ };
127
+
128
+ describe('Specific setting configuration', () => {
129
+ it('should configure a specific setting', async () => {
130
+ setupExtension('test-ext', [
131
+ { name: 'Test Setting', envVar: 'TEST_VAR' },
132
+ ]);
133
+ (updateSetting as Mock).mockResolvedValue(undefined);
134
+
135
+ await runCommand('config test-ext TEST_VAR');
136
+
137
+ expect(updateSetting).toHaveBeenCalledWith(
138
+ expect.objectContaining({ name: 'test-ext' }),
139
+ 'test-id',
140
+ 'TEST_VAR',
141
+ expect.any(Function),
142
+ 'user',
143
+ tempWorkspaceDir,
144
+ );
145
+ });
146
+
147
+ it('should handle missing extension', async () => {
148
+ mockExtensionManager.getExtensions.mockReturnValue([]);
149
+
150
+ await runCommand('config missing-ext TEST_VAR');
151
+
152
+ expect(updateSetting).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('should reject invalid extension names', async () => {
156
+ await runCommand('config ../invalid TEST_VAR');
157
+ expect(debugLogger.error).toHaveBeenCalledWith(
158
+ expect.stringContaining('Invalid extension name'),
159
+ );
160
+
161
+ await runCommand('config ext/with/slash TEST_VAR');
162
+ expect(debugLogger.error).toHaveBeenCalledWith(
163
+ expect.stringContaining('Invalid extension name'),
164
+ );
165
+ });
166
+ });
167
+
168
+ describe('Extension configuration (all settings)', () => {
169
+ it('should configure all settings for an extension', async () => {
170
+ const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
171
+ setupExtension('test-ext', settings);
172
+ (getScopedEnvContents as Mock).mockResolvedValue({});
173
+ (updateSetting as Mock).mockResolvedValue(undefined);
174
+
175
+ await runCommand('config test-ext');
176
+
177
+ expect(debugLogger.log).toHaveBeenCalledWith(
178
+ 'Configuring settings for "test-ext"...',
179
+ );
180
+ expect(updateSetting).toHaveBeenCalledWith(
181
+ expect.objectContaining({ name: 'test-ext' }),
182
+ 'test-id',
183
+ 'VAR_1',
184
+ expect.any(Function),
185
+ 'user',
186
+ tempWorkspaceDir,
187
+ );
188
+ });
189
+
190
+ it('should verify overwrite if setting is already set', async () => {
191
+ const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
192
+ setupExtension('test-ext', settings);
193
+ (getScopedEnvContents as Mock).mockImplementation(
194
+ async (_config, _id, scope) => {
195
+ if (scope === 'user') return { VAR_1: 'existing' };
196
+ return {};
197
+ },
198
+ );
199
+ (prompts as unknown as Mock).mockResolvedValue({ confirm: true });
200
+ (updateSetting as Mock).mockResolvedValue(undefined);
201
+
202
+ await runCommand('config test-ext');
203
+
204
+ expect(prompts).toHaveBeenCalledWith(
205
+ expect.objectContaining({
206
+ type: 'confirm',
207
+ message: expect.stringContaining('is already set. Overwrite?'),
208
+ }),
209
+ );
210
+ expect(updateSetting).toHaveBeenCalled();
211
+ });
212
+
213
+ it('should note if setting is configured in workspace', async () => {
214
+ const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
215
+ setupExtension('test-ext', settings);
216
+ (getScopedEnvContents as Mock).mockImplementation(
217
+ async (_config, _id, scope) => {
218
+ if (scope === 'workspace') return { VAR_1: 'workspace_value' };
219
+ return {};
220
+ },
221
+ );
222
+ (updateSetting as Mock).mockResolvedValue(undefined);
223
+
224
+ await runCommand('config test-ext');
225
+
226
+ expect(debugLogger.log).toHaveBeenCalledWith(
227
+ expect.stringContaining('is already configured in the workspace scope'),
228
+ );
229
+ });
230
+
231
+ it('should skip update if user denies overwrite', async () => {
232
+ const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
233
+ setupExtension('test-ext', settings);
234
+ (getScopedEnvContents as Mock).mockResolvedValue({ VAR_1: 'existing' });
235
+ (prompts as unknown as Mock).mockResolvedValue({ confirm: false });
236
+
237
+ await runCommand('config test-ext');
238
+
239
+ expect(prompts).toHaveBeenCalled();
240
+ expect(updateSetting).not.toHaveBeenCalled();
241
+ });
242
+ });
243
+
244
+ describe('Configure all extensions', () => {
245
+ it('should configure settings for all installed extensions', async () => {
246
+ const ext1 = {
247
+ name: 'ext1',
248
+ path: '/p1',
249
+ id: 'id1',
250
+ settings: [{ envVar: 'V1' }],
251
+ };
252
+ const ext2 = {
253
+ name: 'ext2',
254
+ path: '/p2',
255
+ id: 'id2',
256
+ settings: [{ envVar: 'V2' }],
257
+ };
258
+ mockExtensionManager.getExtensions.mockReturnValue([ext1, ext2]);
259
+
260
+ mockExtensionManager.loadExtensionConfig.mockImplementation(
261
+ async (path) => {
262
+ if (path === '/p1')
263
+ return { name: 'ext1', settings: [{ name: 'S1', envVar: 'V1' }] };
264
+ if (path === '/p2')
265
+ return { name: 'ext2', settings: [{ name: 'S2', envVar: 'V2' }] };
266
+ return null;
267
+ },
268
+ );
269
+
270
+ (getScopedEnvContents as Mock).mockResolvedValue({});
271
+ (updateSetting as Mock).mockResolvedValue(undefined);
272
+
273
+ await runCommand('config');
274
+
275
+ expect(debugLogger.log).toHaveBeenCalledWith(
276
+ expect.stringContaining('Configuring settings for "ext1"'),
277
+ );
278
+ expect(debugLogger.log).toHaveBeenCalledWith(
279
+ expect.stringContaining('Configuring settings for "ext2"'),
280
+ );
281
+ expect(updateSetting).toHaveBeenCalledTimes(2);
282
+ });
283
+
284
+ it('should log if no extensions installed', async () => {
285
+ mockExtensionManager.getExtensions.mockReturnValue([]);
286
+ await runCommand('config');
287
+ expect(debugLogger.log).toHaveBeenCalledWith('No extensions installed.');
288
+ });
289
+ });
290
+ });
packages/cli/src/commands/extensions/configure.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { CommandModule } from 'yargs';
8
+ import type { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
9
+ import {
10
+ configureAllExtensions,
11
+ configureExtension,
12
+ configureSpecificSetting,
13
+ getExtensionManager,
14
+ } from './utils.js';
15
+ import { loadSettings } from '../../config/settings.js';
16
+ import { coreEvents, debugLogger } from '@google/gemini-cli-core';
17
+ import { exitCli } from '../utils.js';
18
+
19
+ interface ConfigureArgs {
20
+ name?: string;
21
+ setting?: string;
22
+ scope: string;
23
+ }
24
+
25
+ export const configureCommand: CommandModule<object, ConfigureArgs> = {
26
+ command: 'config [name] [setting]',
27
+ describe: 'Configure extension settings.',
28
+ builder: (yargs) =>
29
+ yargs
30
+ .positional('name', {
31
+ describe: 'Name of the extension to configure.',
32
+ type: 'string',
33
+ })
34
+ .positional('setting', {
35
+ describe: 'The specific setting to configure (name or env var).',
36
+ type: 'string',
37
+ })
38
+ .option('scope', {
39
+ describe: 'The scope to set the setting in.',
40
+ type: 'string',
41
+ choices: ['user', 'workspace'],
42
+ default: 'user',
43
+ }),
44
+ handler: async (args) => {
45
+ const { name, setting, scope } = args;
46
+ const settings = loadSettings(process.cwd()).merged;
47
+
48
+ if (!(settings.experimental?.extensionConfig ?? true)) {
49
+ coreEvents.emitFeedback(
50
+ 'error',
51
+ 'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.',
52
+ );
53
+ await exitCli();
54
+ return;
55
+ }
56
+
57
+ if (name) {
58
+ if (name.includes('/') || name.includes('\\') || name.includes('..')) {
59
+ debugLogger.error(
60
+ 'Invalid extension name. Names cannot contain path separators or "..".',
61
+ );
62
+ return;
63
+ }
64
+ }
65
+
66
+ const extensionManager = await getExtensionManager();
67
+
68
+ // Case 1: Configure specific setting for an extension
69
+ if (name && setting) {
70
+ await configureSpecificSetting(
71
+ extensionManager,
72
+ name,
73
+ setting,
74
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
75
+ scope as ExtensionSettingScope,
76
+ );
77
+ }
78
+ // Case 2: Configure all settings for an extension
79
+ else if (name) {
80
+ await configureExtension(
81
+ extensionManager,
82
+ name,
83
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
84
+ scope as ExtensionSettingScope,
85
+ );
86
+ }
87
+ // Case 3: Configure all extensions
88
+ else {
89
+ await configureAllExtensions(
90
+ extensionManager,
91
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
92
+ scope as ExtensionSettingScope,
93
+ );
94
+ }
95
+
96
+ await exitCli();
97
+ },
98
+ };
packages/cli/src/commands/extensions/disable.test.ts ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ vi,
9
+ describe,
10
+ it,
11
+ expect,
12
+ beforeEach,
13
+ afterEach,
14
+ type Mock,
15
+ } from 'vitest';
16
+ import { format } from 'node:util';
17
+ import { type Argv } from 'yargs';
18
+ import { handleDisable, disableCommand } from './disable.js';
19
+ import { ExtensionManager } from '../../config/extension-manager.js';
20
+ import {
21
+ loadSettings,
22
+ SettingScope,
23
+ type LoadedSettings,
24
+ } from '../../config/settings.js';
25
+ import { getErrorMessage } from '@google/gemini-cli-core';
26
+
27
+ // Mock dependencies
28
+ const emitConsoleLog = vi.hoisted(() => vi.fn());
29
+ const debugLogger = vi.hoisted(() => ({
30
+ log: vi.fn((message, ...args) => {
31
+ emitConsoleLog('log', format(message, ...args));
32
+ }),
33
+ error: vi.fn((message, ...args) => {
34
+ emitConsoleLog('error', format(message, ...args));
35
+ }),
36
+ }));
37
+
38
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
39
+ const actual =
40
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
41
+ return {
42
+ ...actual,
43
+ coreEvents: {
44
+ emitConsoleLog,
45
+ },
46
+ debugLogger,
47
+ getErrorMessage: vi.fn(),
48
+ };
49
+ });
50
+
51
+ vi.mock('../../config/extension-manager.js');
52
+ vi.mock('../../config/settings.js');
53
+ vi.mock('../../config/extensions/consent.js', () => ({
54
+ requestConsentNonInteractive: vi.fn(),
55
+ }));
56
+ vi.mock('../../config/extensions/extensionSettings.js', () => ({
57
+ promptForSetting: vi.fn(),
58
+ }));
59
+ vi.mock('../utils.js', () => ({
60
+ exitCli: vi.fn(),
61
+ }));
62
+
63
+ describe('extensions disable command', () => {
64
+ const mockLoadSettings = vi.mocked(loadSettings);
65
+ const mockGetErrorMessage = vi.mocked(getErrorMessage);
66
+ const mockExtensionManager = vi.mocked(ExtensionManager);
67
+
68
+ beforeEach(async () => {
69
+ vi.clearAllMocks();
70
+ mockLoadSettings.mockReturnValue({
71
+ merged: {},
72
+ } as unknown as LoadedSettings);
73
+ mockExtensionManager.prototype.loadExtensions = vi
74
+ .fn()
75
+ .mockResolvedValue(undefined);
76
+ mockExtensionManager.prototype.disableExtension = vi
77
+ .fn()
78
+ .mockResolvedValue(undefined);
79
+ });
80
+
81
+ afterEach(() => {
82
+ vi.restoreAllMocks();
83
+ });
84
+
85
+ describe('handleDisable', () => {
86
+ it.each([
87
+ {
88
+ name: 'my-extension',
89
+ scope: undefined,
90
+ expectedScope: SettingScope.User,
91
+ expectedLog:
92
+ 'Extension "my-extension" successfully disabled for scope "undefined".',
93
+ },
94
+ {
95
+ name: 'my-extension',
96
+ scope: 'user',
97
+ expectedScope: SettingScope.User,
98
+ expectedLog:
99
+ 'Extension "my-extension" successfully disabled for scope "user".',
100
+ },
101
+ {
102
+ name: 'my-extension',
103
+ scope: 'workspace',
104
+ expectedScope: SettingScope.Workspace,
105
+ expectedLog:
106
+ 'Extension "my-extension" successfully disabled for scope "workspace".',
107
+ },
108
+ ])(
109
+ 'should disable an extension in the $expectedScope scope when scope is $scope',
110
+ async ({ name, scope, expectedScope, expectedLog }) => {
111
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
112
+ await handleDisable({ name, scope });
113
+ expect(mockExtensionManager).toHaveBeenCalledWith(
114
+ expect.objectContaining({
115
+ workspaceDir: '/test/dir',
116
+ }),
117
+ );
118
+ expect(
119
+ mockExtensionManager.prototype.loadExtensions,
120
+ ).toHaveBeenCalled();
121
+ expect(
122
+ mockExtensionManager.prototype.disableExtension,
123
+ ).toHaveBeenCalledWith(name, expectedScope);
124
+ expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
125
+ mockCwd.mockRestore();
126
+ },
127
+ );
128
+
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);
135
+ const error = new Error('Disable failed');
136
+ (
137
+ mockExtensionManager.prototype.disableExtension as Mock
138
+ ).mockRejectedValue(error);
139
+ mockGetErrorMessage.mockReturnValue('Disable failed message');
140
+ await handleDisable({ name: 'my-extension' });
141
+ expect(emitConsoleLog).toHaveBeenCalledWith(
142
+ 'error',
143
+ 'Disable failed message',
144
+ );
145
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
146
+ mockProcessExit.mockRestore();
147
+ });
148
+ });
149
+
150
+ describe('disableCommand', () => {
151
+ const command = disableCommand;
152
+
153
+ it('should have correct command and describe', () => {
154
+ expect(command.command).toBe('disable [--scope] <name>');
155
+ expect(command.describe).toBe('Disables an extension.');
156
+ });
157
+
158
+ describe('builder', () => {
159
+ interface MockYargs {
160
+ positional: Mock;
161
+ option: Mock;
162
+ check: Mock;
163
+ }
164
+
165
+ let yargsMock: MockYargs;
166
+
167
+ beforeEach(() => {
168
+ yargsMock = {
169
+ positional: vi.fn().mockReturnThis(),
170
+ option: vi.fn().mockReturnThis(),
171
+ check: vi.fn().mockReturnThis(),
172
+ };
173
+ });
174
+
175
+ it('should configure positional and option arguments', () => {
176
+ (command.builder as (yargs: Argv) => Argv)(
177
+ yargsMock as unknown as Argv,
178
+ );
179
+ expect(yargsMock.positional).toHaveBeenCalledWith('name', {
180
+ describe: 'The name of the extension to disable.',
181
+ type: 'string',
182
+ });
183
+ expect(yargsMock.option).toHaveBeenCalledWith('scope', {
184
+ describe: 'The scope to disable the extension in.',
185
+ type: 'string',
186
+ default: SettingScope.User,
187
+ });
188
+ expect(yargsMock.check).toHaveBeenCalled();
189
+ });
190
+
191
+ it('check function should throw for invalid scope', () => {
192
+ (command.builder as (yargs: Argv) => Argv)(
193
+ yargsMock as unknown as Argv,
194
+ );
195
+ const checkCallback = yargsMock.check.mock.calls[0][0];
196
+ const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
197
+ SettingScope,
198
+ )
199
+ .map((s) => s.toLowerCase())
200
+ .join(', ')}.`;
201
+ expect(() => checkCallback({ scope: 'invalid' })).toThrow(
202
+ expectedError,
203
+ );
204
+ });
205
+
206
+ it.each(['user', 'workspace', 'USER', 'WorkSpace'])(
207
+ 'check function should return true for valid scope "%s"',
208
+ (scope) => {
209
+ (command.builder as (yargs: Argv) => Argv)(
210
+ yargsMock as unknown as Argv,
211
+ );
212
+ const checkCallback = yargsMock.check.mock.calls[0][0];
213
+ expect(checkCallback({ scope })).toBe(true);
214
+ },
215
+ );
216
+ });
217
+
218
+ it('handler should trigger extension disabling', async () => {
219
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
220
+ interface TestArgv {
221
+ name: string;
222
+ scope: string;
223
+ [key: string]: unknown;
224
+ }
225
+ const argv: TestArgv = {
226
+ name: 'test-ext',
227
+ scope: 'workspace',
228
+ _: [],
229
+ $0: '',
230
+ };
231
+ await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
232
+ argv,
233
+ );
234
+ expect(mockExtensionManager).toHaveBeenCalledWith(
235
+ expect.objectContaining({
236
+ workspaceDir: '/test/dir',
237
+ }),
238
+ );
239
+ expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
240
+ expect(
241
+ mockExtensionManager.prototype.disableExtension,
242
+ ).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
243
+ expect(emitConsoleLog).toHaveBeenCalledWith(
244
+ 'log',
245
+ 'Extension "test-ext" successfully disabled for scope "workspace".',
246
+ );
247
+ mockCwd.mockRestore();
248
+ });
249
+ });
250
+ });
packages/cli/src/commands/extensions/disable.ts ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { type CommandModule } from 'yargs';
8
+ import { loadSettings, SettingScope } from '../../config/settings.js';
9
+ import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
10
+ import { ExtensionManager } from '../../config/extension-manager.js';
11
+ import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
12
+ import { promptForSetting } from '../../config/extensions/extensionSettings.js';
13
+ import { exitCli } from '../utils.js';
14
+
15
+ interface DisableArgs {
16
+ name: string;
17
+ scope?: string;
18
+ }
19
+
20
+ export async function handleDisable(args: DisableArgs) {
21
+ const workspaceDir = process.cwd();
22
+ const extensionManager = new ExtensionManager({
23
+ workspaceDir,
24
+ requestConsent: requestConsentNonInteractive,
25
+ requestSetting: promptForSetting,
26
+ settings: loadSettings(workspaceDir).merged,
27
+ });
28
+ await extensionManager.loadExtensions();
29
+
30
+ try {
31
+ if (args.scope?.toLowerCase() === 'workspace') {
32
+ await extensionManager.disableExtension(
33
+ args.name,
34
+ SettingScope.Workspace,
35
+ );
36
+ } else {
37
+ await extensionManager.disableExtension(args.name, SettingScope.User);
38
+ }
39
+ debugLogger.log(
40
+ `Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
41
+ );
42
+ } catch (error) {
43
+ debugLogger.error(getErrorMessage(error));
44
+ process.exit(1);
45
+ }
46
+ }
47
+
48
+ export const disableCommand: CommandModule = {
49
+ command: 'disable [--scope] <name>',
50
+ describe: 'Disables an extension.',
51
+ builder: (yargs) =>
52
+ yargs
53
+ .positional('name', {
54
+ describe: 'The name of the extension to disable.',
55
+ type: 'string',
56
+ })
57
+ .option('scope', {
58
+ describe: 'The scope to disable the extension in.',
59
+ type: 'string',
60
+ default: SettingScope.User,
61
+ })
62
+ .check((argv) => {
63
+ if (
64
+ argv.scope &&
65
+ !Object.values(SettingScope)
66
+ .map((s) => s.toLowerCase())
67
+ .includes(argv.scope.toLowerCase())
68
+ ) {
69
+ throw new Error(
70
+ `Invalid scope: ${argv.scope}. Please use one of ${Object.values(
71
+ SettingScope,
72
+ )
73
+ .map((s) => s.toLowerCase())
74
+ .join(', ')}.`,
75
+ );
76
+ }
77
+ return true;
78
+ }),
79
+ handler: async (argv) => {
80
+ await handleDisable({
81
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
82
+ name: argv['name'] as string,
83
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
84
+ scope: argv['scope'] as string,
85
+ });
86
+ await exitCli();
87
+ },
88
+ };
packages/cli/src/commands/extensions/enable.test.ts ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ vi,
9
+ describe,
10
+ it,
11
+ expect,
12
+ beforeEach,
13
+ afterEach,
14
+ type Mock,
15
+ } from 'vitest';
16
+ import { format } from 'node:util';
17
+ import { type Argv } from 'yargs';
18
+ import { handleEnable, enableCommand } from './enable.js';
19
+ import { ExtensionManager } from '../../config/extension-manager.js';
20
+ import {
21
+ loadSettings,
22
+ SettingScope,
23
+ type LoadedSettings,
24
+ } from '../../config/settings.js';
25
+ import { FatalConfigError } from '@google/gemini-cli-core';
26
+
27
+ // Mock dependencies
28
+ const emitConsoleLog = vi.hoisted(() => vi.fn());
29
+ const debugLogger = vi.hoisted(() => ({
30
+ log: vi.fn((message, ...args) => {
31
+ emitConsoleLog('log', format(message, ...args));
32
+ }),
33
+ error: vi.fn((message, ...args) => {
34
+ emitConsoleLog('error', format(message, ...args));
35
+ }),
36
+ }));
37
+
38
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
39
+ const actual =
40
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
41
+ return {
42
+ ...actual,
43
+ coreEvents: {
44
+ emitConsoleLog,
45
+ },
46
+ debugLogger,
47
+ getErrorMessage: vi.fn((error: { message: string }) => error.message),
48
+ FatalConfigError: class extends Error {
49
+ constructor(message: string) {
50
+ super(message);
51
+ this.name = 'FatalConfigError';
52
+ }
53
+ },
54
+ };
55
+ });
56
+
57
+ vi.mock('../../config/extension-manager.js');
58
+ vi.mock('../../config/settings.js');
59
+ vi.mock('../../config/extensions/consent.js');
60
+ vi.mock('../../config/extensions/extensionSettings.js');
61
+ vi.mock('../utils.js', () => ({
62
+ exitCli: vi.fn(),
63
+ }));
64
+
65
+ const mockEnablementInstance = vi.hoisted(() => ({
66
+ getDisplayState: vi.fn(),
67
+ enable: vi.fn(),
68
+ clearSessionDisable: vi.fn(),
69
+ autoEnableServers: vi.fn(),
70
+ }));
71
+ vi.mock('../../config/mcp/mcpServerEnablement.js', () => ({
72
+ McpServerEnablementManager: {
73
+ getInstance: () => mockEnablementInstance,
74
+ },
75
+ }));
76
+
77
+ describe('extensions enable command', () => {
78
+ const mockLoadSettings = vi.mocked(loadSettings);
79
+ const mockExtensionManager = vi.mocked(ExtensionManager);
80
+
81
+ beforeEach(async () => {
82
+ vi.clearAllMocks();
83
+ mockLoadSettings.mockReturnValue({
84
+ merged: {},
85
+ } as unknown as LoadedSettings);
86
+ mockExtensionManager.prototype.loadExtensions = vi
87
+ .fn()
88
+ .mockResolvedValue(undefined);
89
+ mockExtensionManager.prototype.enableExtension = vi.fn();
90
+ mockExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
91
+ mockEnablementInstance.getDisplayState.mockReset();
92
+ mockEnablementInstance.enable.mockReset();
93
+ mockEnablementInstance.clearSessionDisable.mockReset();
94
+ mockEnablementInstance.autoEnableServers.mockReset();
95
+ mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
96
+ });
97
+
98
+ afterEach(() => {
99
+ vi.restoreAllMocks();
100
+ });
101
+
102
+ describe('handleEnable', () => {
103
+ it.each([
104
+ {
105
+ name: 'my-extension',
106
+ scope: undefined,
107
+ expectedScope: SettingScope.User,
108
+ expectedLog:
109
+ 'Extension "my-extension" successfully enabled in all scopes.',
110
+ },
111
+ {
112
+ name: 'my-extension',
113
+ scope: 'workspace',
114
+ expectedScope: SettingScope.Workspace,
115
+ expectedLog:
116
+ 'Extension "my-extension" successfully enabled for scope "workspace".',
117
+ },
118
+ ])(
119
+ 'should enable an extension in the $expectedScope scope when scope is $scope',
120
+ async ({ name, scope, expectedScope, expectedLog }) => {
121
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
122
+ await handleEnable({ name, scope });
123
+
124
+ expect(mockExtensionManager).toHaveBeenCalledWith(
125
+ expect.objectContaining({
126
+ workspaceDir: '/test/dir',
127
+ }),
128
+ );
129
+ expect(
130
+ mockExtensionManager.prototype.loadExtensions,
131
+ ).toHaveBeenCalled();
132
+ expect(
133
+ mockExtensionManager.prototype.enableExtension,
134
+ ).toHaveBeenCalledWith(name, expectedScope);
135
+ expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
136
+ mockCwd.mockRestore();
137
+ },
138
+ );
139
+
140
+ it('should throw FatalConfigError when extension enabling fails', async () => {
141
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
142
+ const error = new Error('Enable failed');
143
+ (
144
+ mockExtensionManager.prototype.enableExtension as Mock
145
+ ).mockImplementation(() => {
146
+ throw error;
147
+ });
148
+
149
+ const promise = handleEnable({ name: 'my-extension' });
150
+ await expect(promise).rejects.toThrow(FatalConfigError);
151
+ await expect(promise).rejects.toThrow('Enable failed');
152
+
153
+ mockCwd.mockRestore();
154
+ });
155
+
156
+ it('should auto-enable disabled MCP servers for the extension', async () => {
157
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
158
+ mockEnablementInstance.autoEnableServers.mockResolvedValue([
159
+ 'test-server',
160
+ ]);
161
+ mockExtensionManager.prototype.getExtensions = vi
162
+ .fn()
163
+ .mockReturnValue([
164
+ { name: 'my-extension', mcpServers: { 'test-server': {} } },
165
+ ]);
166
+
167
+ await handleEnable({ name: 'my-extension' });
168
+
169
+ expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
170
+ 'test-server',
171
+ ]);
172
+ expect(emitConsoleLog).toHaveBeenCalledWith(
173
+ 'log',
174
+ expect.stringContaining("MCP server 'test-server' was disabled"),
175
+ );
176
+ mockCwd.mockRestore();
177
+ });
178
+
179
+ it('should not log when MCP servers are already enabled', async () => {
180
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
181
+ mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
182
+ mockExtensionManager.prototype.getExtensions = vi
183
+ .fn()
184
+ .mockReturnValue([
185
+ { name: 'my-extension', mcpServers: { 'test-server': {} } },
186
+ ]);
187
+
188
+ await handleEnable({ name: 'my-extension' });
189
+
190
+ expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
191
+ 'test-server',
192
+ ]);
193
+ expect(emitConsoleLog).not.toHaveBeenCalledWith(
194
+ 'log',
195
+ expect.stringContaining("MCP server 'test-server' was disabled"),
196
+ );
197
+ mockCwd.mockRestore();
198
+ });
199
+ });
200
+
201
+ describe('enableCommand', () => {
202
+ const command = enableCommand;
203
+
204
+ it('should have correct command and describe', () => {
205
+ expect(command.command).toBe('enable [--scope] <name>');
206
+ expect(command.describe).toBe('Enables an extension.');
207
+ });
208
+
209
+ describe('builder', () => {
210
+ interface MockYargs {
211
+ positional: Mock;
212
+ option: Mock;
213
+ check: Mock;
214
+ }
215
+
216
+ let yargsMock: MockYargs;
217
+ beforeEach(() => {
218
+ yargsMock = {
219
+ positional: vi.fn().mockReturnThis(),
220
+ option: vi.fn().mockReturnThis(),
221
+ check: vi.fn().mockReturnThis(),
222
+ };
223
+ });
224
+
225
+ it('should configure positional and option arguments', () => {
226
+ (command.builder as (yargs: Argv) => Argv)(
227
+ yargsMock as unknown as Argv,
228
+ );
229
+ expect(yargsMock.positional).toHaveBeenCalledWith('name', {
230
+ describe: 'The name of the extension to enable.',
231
+ type: 'string',
232
+ });
233
+ expect(yargsMock.option).toHaveBeenCalledWith('scope', {
234
+ describe:
235
+ 'The scope to enable the extension in. If not set, will be enabled in all scopes.',
236
+ type: 'string',
237
+ });
238
+ expect(yargsMock.check).toHaveBeenCalled();
239
+ });
240
+
241
+ it('check function should throw for invalid scope', () => {
242
+ (command.builder as (yargs: Argv) => Argv)(
243
+ yargsMock as unknown as Argv,
244
+ );
245
+ const checkCallback = yargsMock.check.mock.calls[0][0];
246
+ const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
247
+ SettingScope,
248
+ )
249
+ .map((s) => s.toLowerCase())
250
+ .join(', ')}.`;
251
+ expect(() => checkCallback({ scope: 'invalid' })).toThrow(
252
+ expectedError,
253
+ );
254
+ });
255
+ });
256
+
257
+ it('handler should call handleEnable', async () => {
258
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
259
+ interface TestArgv {
260
+ name: string;
261
+ scope: string;
262
+ [key: string]: unknown;
263
+ }
264
+ const argv: TestArgv = {
265
+ name: 'test-ext',
266
+ scope: 'workspace',
267
+ _: [],
268
+ $0: '',
269
+ };
270
+ await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
271
+ argv,
272
+ );
273
+
274
+ expect(
275
+ mockExtensionManager.prototype.enableExtension,
276
+ ).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
277
+ mockCwd.mockRestore();
278
+ });
279
+ });
280
+ });
packages/cli/src/commands/extensions/enable.ts ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { type CommandModule } from 'yargs';
8
+ import { loadSettings, SettingScope } from '../../config/settings.js';
9
+ import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
10
+ import { ExtensionManager } from '../../config/extension-manager.js';
11
+ import {
12
+ debugLogger,
13
+ FatalConfigError,
14
+ getErrorMessage,
15
+ } from '@google/gemini-cli-core';
16
+ import { promptForSetting } from '../../config/extensions/extensionSettings.js';
17
+ import { exitCli } from '../utils.js';
18
+ import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
19
+
20
+ interface EnableArgs {
21
+ name: string;
22
+ scope?: string;
23
+ }
24
+
25
+ export async function handleEnable(args: EnableArgs) {
26
+ const workingDir = process.cwd();
27
+ const extensionManager = new ExtensionManager({
28
+ workspaceDir: workingDir,
29
+ requestConsent: requestConsentNonInteractive,
30
+ requestSetting: promptForSetting,
31
+ settings: loadSettings(workingDir).merged,
32
+ });
33
+ await extensionManager.loadExtensions();
34
+
35
+ try {
36
+ if (args.scope?.toLowerCase() === 'workspace') {
37
+ await extensionManager.enableExtension(args.name, SettingScope.Workspace);
38
+ } else {
39
+ await extensionManager.enableExtension(args.name, SettingScope.User);
40
+ }
41
+
42
+ // Auto-enable any disabled MCP servers for this extension
43
+ const extension = extensionManager
44
+ .getExtensions()
45
+ .find((e) => e.name === args.name);
46
+
47
+ if (extension?.mcpServers) {
48
+ const mcpEnablementManager = McpServerEnablementManager.getInstance();
49
+ const enabledServers = await mcpEnablementManager.autoEnableServers(
50
+ Object.keys(extension.mcpServers ?? {}),
51
+ );
52
+
53
+ for (const serverName of enabledServers) {
54
+ debugLogger.log(
55
+ `MCP server '${serverName}' was disabled - now enabled.`,
56
+ );
57
+ }
58
+ // Note: No restartServer() - CLI exits immediately, servers load on next session
59
+ }
60
+
61
+ if (args.scope) {
62
+ debugLogger.log(
63
+ `Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
64
+ );
65
+ } else {
66
+ debugLogger.log(
67
+ `Extension "${args.name}" successfully enabled in all scopes.`,
68
+ );
69
+ }
70
+ } catch (error) {
71
+ throw new FatalConfigError(getErrorMessage(error));
72
+ }
73
+ }
74
+
75
+ export const enableCommand: CommandModule = {
76
+ command: 'enable [--scope] <name>',
77
+ describe: 'Enables an extension.',
78
+ builder: (yargs) =>
79
+ yargs
80
+ .positional('name', {
81
+ describe: 'The name of the extension to enable.',
82
+ type: 'string',
83
+ })
84
+ .option('scope', {
85
+ describe:
86
+ 'The scope to enable the extension in. If not set, will be enabled in all scopes.',
87
+ type: 'string',
88
+ })
89
+ .check((argv) => {
90
+ if (
91
+ argv.scope &&
92
+ !Object.values(SettingScope)
93
+ .map((s) => s.toLowerCase())
94
+ .includes(argv.scope.toLowerCase())
95
+ ) {
96
+ throw new Error(
97
+ `Invalid scope: ${argv.scope}. Please use one of ${Object.values(
98
+ SettingScope,
99
+ )
100
+ .map((s) => s.toLowerCase())
101
+ .join(', ')}.`,
102
+ );
103
+ }
104
+ return true;
105
+ }),
106
+ handler: async (argv) => {
107
+ await handleEnable({
108
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
109
+ name: argv['name'] as string,
110
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
111
+ scope: argv['scope'] as string,
112
+ });
113
+ await exitCli();
114
+ },
115
+ };
packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-error.log
5
+ yarn-debug.log
6
+
7
+ # Build output
8
+ dist/
9
+
10
+ # OS metadata
11
+ .DS_Store
12
+ Thumbs.db
13
+
14
+ # TypeScript
15
+ *.tsbuildinfo
16
+
17
+ # Environment variables
18
+ .env
19
+ .env.local
20
+ .env.development.local
21
+ .env.test.local
22
+ .env.production.local
23
+
24
+ # IDEs
25
+ .vscode/
26
+ .idea/
packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "excludeTools",
3
+ "version": "1.0.0",
4
+ "excludeTools": ["run_shell_command(rm -rf)"]
5
+ }
packages/cli/src/commands/extensions/examples/mcp-server/.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-error.log
5
+ yarn-debug.log
6
+
7
+ # Build output
8
+ dist/
9
+
10
+ # OS metadata
11
+ .DS_Store
12
+ Thumbs.db
13
+
14
+ # TypeScript
15
+ *.tsbuildinfo
16
+
17
+ # Environment variables
18
+ .env
19
+ .env.local
20
+ .env.development.local
21
+ .env.test.local
22
+ .env.production.local
23
+
24
+ # IDEs
25
+ .vscode/
26
+ .idea/
packages/cli/src/commands/extensions/examples/mcp-server/README.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MCP Server Example
2
+
3
+ This is a basic example of an MCP (Model Context Protocol) server used as a
4
+ Gemini CLI extension. It demonstrates how to expose tools and prompts to the
5
+ Gemini CLI.
6
+
7
+ ## Description
8
+
9
+ The contents of this directory are a valid MCP server implementation using the
10
+ `@modelcontextprotocol/sdk`. It exposes:
11
+
12
+ - A tool `fetch_posts` that mock-fetches posts.
13
+ - A prompt `poem-writer`.
14
+
15
+ ## Structure
16
+
17
+ - `example.js`: The main server entry point.
18
+ - `gemini-extension.json`: The configuration file that tells Gemini CLI how to
19
+ use this extension.
20
+ - `package.json`: Helper for dependencies.
21
+
22
+ ## How to Use
23
+
24
+ 1. Navigate to this directory:
25
+
26
+ ```bash
27
+ cd packages/cli/src/commands/extensions/examples/mcp-server
28
+ ```
29
+
30
+ 2. Install dependencies:
31
+ ```bash
32
+ npm install
33
+ ```
34
+
35
+ This example is typically used by `gemini extensions new`.
packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "mcp-server-example",
3
+ "version": "1.0.0",
4
+ "mcpServers": {
5
+ "nodeServer": {
6
+ "command": "node",
7
+ "args": ["${extensionPath}${/}example.js"],
8
+ "cwd": "${extensionPath}"
9
+ }
10
+ }
11
+ }
packages/cli/src/commands/extensions/install.test.ts ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ describe,
9
+ it,
10
+ expect,
11
+ vi,
12
+ beforeEach,
13
+ afterEach,
14
+ type MockInstance,
15
+ } from 'vitest';
16
+ import { handleInstall, installCommand } from './install.js';
17
+ import yargs from 'yargs';
18
+ import * as core from '@google/gemini-cli-core';
19
+ import type { Stats } from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import { promptForSetting } from '../../config/extensions/extensionSettings.js';
22
+
23
+ const {
24
+ mockInstallOrUpdateExtension,
25
+ mockLoadExtensions,
26
+ mockExtensionManager,
27
+ mockRequestConsentNonInteractive,
28
+ mockPromptForConsentNonInteractive,
29
+ mockStat,
30
+ mockInferInstallMetadata,
31
+ mockIsWorkspaceTrusted,
32
+ mockLoadTrustedFolders,
33
+ mockDiscover,
34
+ } = vi.hoisted(() => {
35
+ const mockLoadExtensions = vi.fn();
36
+ const mockInstallOrUpdateExtension = vi.fn();
37
+ const mockExtensionManager = vi.fn().mockImplementation(() => ({
38
+ loadExtensions: mockLoadExtensions,
39
+ installOrUpdateExtension: mockInstallOrUpdateExtension,
40
+ }));
41
+
42
+ return {
43
+ mockLoadExtensions,
44
+ mockInstallOrUpdateExtension,
45
+ mockExtensionManager,
46
+ mockRequestConsentNonInteractive: vi.fn(),
47
+ mockPromptForConsentNonInteractive: vi.fn(),
48
+ mockStat: vi.fn(),
49
+ mockInferInstallMetadata: vi.fn(),
50
+ mockIsWorkspaceTrusted: vi.fn(),
51
+ mockLoadTrustedFolders: vi.fn(),
52
+ mockDiscover: vi.fn(),
53
+ };
54
+ });
55
+
56
+ vi.mock('../../config/extensions/consent.js', () => ({
57
+ requestConsentNonInteractive: mockRequestConsentNonInteractive,
58
+ promptForConsentNonInteractive: mockPromptForConsentNonInteractive,
59
+ INSTALL_WARNING_MESSAGE: 'warning',
60
+ }));
61
+
62
+ vi.mock('../../config/trustedFolders.js', () => ({
63
+ isWorkspaceTrusted: mockIsWorkspaceTrusted,
64
+ loadTrustedFolders: mockLoadTrustedFolders,
65
+ TrustLevel: {
66
+ TRUST_FOLDER: 'TRUST_FOLDER',
67
+ },
68
+ }));
69
+
70
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
71
+ const actual =
72
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
73
+ return {
74
+ ...actual,
75
+ FolderTrustDiscoveryService: {
76
+ discover: mockDiscover,
77
+ },
78
+ };
79
+ });
80
+
81
+ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
82
+ ...(await importOriginal<
83
+ typeof import('../../config/extension-manager.js')
84
+ >()),
85
+ ExtensionManager: mockExtensionManager,
86
+ inferInstallMetadata: mockInferInstallMetadata,
87
+ }));
88
+
89
+ vi.mock('../../utils/errors.js', () => ({
90
+ getErrorMessage: vi.fn((error: Error) => error.message),
91
+ }));
92
+
93
+ vi.mock('node:fs/promises', () => ({
94
+ stat: mockStat,
95
+ default: {
96
+ stat: mockStat,
97
+ },
98
+ }));
99
+
100
+ vi.mock('../utils.js', () => ({
101
+ exitCli: vi.fn(),
102
+ }));
103
+
104
+ describe('extensions install command', () => {
105
+ it('should fail if no source is provided', () => {
106
+ const validationParser = yargs([])
107
+ .locale('en')
108
+ .command(installCommand)
109
+ .fail(false);
110
+ expect(() => validationParser.parse('install')).toThrow(
111
+ 'Not enough non-option arguments: got 0, need at least 1',
112
+ );
113
+ });
114
+ });
115
+
116
+ describe('handleInstall', () => {
117
+ let debugLogSpy: MockInstance;
118
+ let debugErrorSpy: MockInstance;
119
+ let processSpy: MockInstance;
120
+
121
+ beforeEach(() => {
122
+ debugLogSpy = vi
123
+ .spyOn(core.debugLogger, 'log')
124
+ .mockImplementation(() => {});
125
+ debugErrorSpy = vi
126
+ .spyOn(core.debugLogger, 'error')
127
+ .mockImplementation(() => {});
128
+ processSpy = vi
129
+ .spyOn(process, 'exit')
130
+ .mockImplementation(() => undefined as never);
131
+
132
+ mockLoadExtensions.mockResolvedValue([]);
133
+ mockInstallOrUpdateExtension.mockReset();
134
+
135
+ mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
136
+ mockDiscover.mockResolvedValue({
137
+ commands: [],
138
+ mcps: [],
139
+ hooks: [],
140
+ skills: [],
141
+ agents: [],
142
+ settings: [],
143
+ securityWarnings: [],
144
+ discoveryErrors: [],
145
+ });
146
+
147
+ mockInferInstallMetadata.mockImplementation(async (source, args) => {
148
+ if (
149
+ source.startsWith('http://') ||
150
+ source.startsWith('https://') ||
151
+ source.startsWith('git@') ||
152
+ source.startsWith('sso://')
153
+ ) {
154
+ return {
155
+ source,
156
+ type: 'git',
157
+ ref: args?.ref,
158
+ autoUpdate: args?.autoUpdate,
159
+ allowPreRelease: args?.allowPreRelease,
160
+ };
161
+ }
162
+ return { source, type: 'local' };
163
+ });
164
+ });
165
+
166
+ afterEach(() => {
167
+ vi.clearAllMocks();
168
+ });
169
+
170
+ function createMockExtension(
171
+ overrides: Partial<core.GeminiCLIExtension> = {},
172
+ ): core.GeminiCLIExtension {
173
+ return {
174
+ name: 'mock-extension',
175
+ version: '1.0.0',
176
+ isActive: true,
177
+ path: '/mock/path',
178
+ contextFiles: [],
179
+ id: 'mock-id',
180
+ ...overrides,
181
+ };
182
+ }
183
+
184
+ it('should install an extension from a http source', async () => {
185
+ mockInstallOrUpdateExtension.mockResolvedValue(
186
+ createMockExtension({
187
+ name: 'http-extension',
188
+ }),
189
+ );
190
+
191
+ await handleInstall({
192
+ source: 'http://google.com',
193
+ });
194
+
195
+ expect(debugLogSpy).toHaveBeenCalledWith(
196
+ 'Extension "http-extension" installed successfully and enabled.',
197
+ );
198
+ });
199
+
200
+ it('should install an extension from a https source', async () => {
201
+ mockInstallOrUpdateExtension.mockResolvedValue(
202
+ createMockExtension({
203
+ name: 'https-extension',
204
+ }),
205
+ );
206
+
207
+ await handleInstall({
208
+ source: 'https://google.com',
209
+ });
210
+
211
+ expect(debugLogSpy).toHaveBeenCalledWith(
212
+ 'Extension "https-extension" installed successfully and enabled.',
213
+ );
214
+ });
215
+
216
+ it('should install an extension from a git source', async () => {
217
+ mockInstallOrUpdateExtension.mockResolvedValue(
218
+ createMockExtension({
219
+ name: 'git-extension',
220
+ }),
221
+ );
222
+
223
+ await handleInstall({
224
+ source: 'git@some-url',
225
+ });
226
+
227
+ expect(debugLogSpy).toHaveBeenCalledWith(
228
+ 'Extension "git-extension" installed successfully and enabled.',
229
+ );
230
+ });
231
+
232
+ it('throws an error from an unknown source', async () => {
233
+ mockInferInstallMetadata.mockRejectedValue(
234
+ new Error('Install source not found.'),
235
+ );
236
+ await handleInstall({
237
+ source: 'test://google.com',
238
+ });
239
+
240
+ expect(debugErrorSpy).toHaveBeenCalledWith('Install source not found.');
241
+ expect(processSpy).toHaveBeenCalledWith(1);
242
+ });
243
+
244
+ it('should install an extension from a sso source', async () => {
245
+ mockInstallOrUpdateExtension.mockResolvedValue(
246
+ createMockExtension({
247
+ name: 'sso-extension',
248
+ }),
249
+ );
250
+
251
+ await handleInstall({
252
+ source: 'sso://google.com',
253
+ });
254
+
255
+ expect(debugLogSpy).toHaveBeenCalledWith(
256
+ 'Extension "sso-extension" installed successfully and enabled.',
257
+ );
258
+ });
259
+
260
+ it('should install an extension from a local path', async () => {
261
+ mockInstallOrUpdateExtension.mockResolvedValue(
262
+ createMockExtension({
263
+ name: 'local-extension',
264
+ }),
265
+ );
266
+ mockStat.mockResolvedValue({} as Stats);
267
+ await handleInstall({
268
+ source: path.join('/', 'some', 'path'),
269
+ });
270
+
271
+ expect(debugLogSpy).toHaveBeenCalledWith(
272
+ 'Extension "local-extension" installed successfully and enabled.',
273
+ );
274
+ });
275
+
276
+ it('should throw an error if install extension fails', async () => {
277
+ mockInstallOrUpdateExtension.mockRejectedValue(
278
+ new Error('Install extension failed'),
279
+ );
280
+
281
+ await handleInstall({ source: 'git@some-url' });
282
+
283
+ expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
284
+ expect(processSpy).toHaveBeenCalledWith(1);
285
+ });
286
+
287
+ it('should pass promptForSetting when skipSettings is not provided', async () => {
288
+ mockInstallOrUpdateExtension.mockResolvedValue({
289
+ name: 'test-extension',
290
+ } as unknown as core.GeminiCLIExtension);
291
+
292
+ await handleInstall({
293
+ source: 'http://google.com',
294
+ });
295
+
296
+ expect(mockExtensionManager).toHaveBeenCalledWith(
297
+ expect.objectContaining({
298
+ requestSetting: promptForSetting,
299
+ }),
300
+ );
301
+ });
302
+
303
+ it('should pass null for requestSetting when skipSettings is true', async () => {
304
+ mockInstallOrUpdateExtension.mockResolvedValue({
305
+ name: 'test-extension',
306
+ } as unknown as core.GeminiCLIExtension);
307
+
308
+ await handleInstall({
309
+ source: 'http://google.com',
310
+ skipSettings: true,
311
+ });
312
+
313
+ expect(mockExtensionManager).toHaveBeenCalledWith(
314
+ expect.objectContaining({
315
+ requestSetting: null,
316
+ }),
317
+ );
318
+ });
319
+
320
+ it('should proceed if local path is already trusted', async () => {
321
+ mockInstallOrUpdateExtension.mockResolvedValue(
322
+ createMockExtension({
323
+ name: 'local-extension',
324
+ }),
325
+ );
326
+ mockStat.mockResolvedValue({} as Stats);
327
+ mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
328
+
329
+ await handleInstall({
330
+ source: path.join('/', 'some', 'path'),
331
+ });
332
+
333
+ expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
334
+ expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled();
335
+ expect(debugLogSpy).toHaveBeenCalledWith(
336
+ 'Extension "local-extension" installed successfully and enabled.',
337
+ );
338
+ });
339
+
340
+ it('should prompt and proceed if user accepts trust', async () => {
341
+ mockInstallOrUpdateExtension.mockResolvedValue(
342
+ createMockExtension({
343
+ name: 'local-extension',
344
+ }),
345
+ );
346
+ mockStat.mockResolvedValue({} as Stats);
347
+ mockIsWorkspaceTrusted.mockReturnValue({
348
+ isTrusted: undefined,
349
+ source: undefined,
350
+ });
351
+ mockPromptForConsentNonInteractive.mockResolvedValue(true);
352
+ const mockSetValue = vi.fn();
353
+ mockLoadTrustedFolders.mockReturnValue({
354
+ setValue: mockSetValue,
355
+ user: { path: '', config: {} },
356
+ errors: [],
357
+ rules: [],
358
+ isPathTrusted: vi.fn(),
359
+ });
360
+
361
+ await handleInstall({
362
+ source: path.join('/', 'untrusted', 'path'),
363
+ });
364
+
365
+ expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
366
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
367
+ expect(mockSetValue).toHaveBeenCalledWith(
368
+ expect.stringContaining(path.join('untrusted', 'path')),
369
+ 'TRUST_FOLDER',
370
+ );
371
+ expect(debugLogSpy).toHaveBeenCalledWith(
372
+ 'Extension "local-extension" installed successfully and enabled.',
373
+ );
374
+ });
375
+
376
+ it('should prompt and abort if user denies trust', async () => {
377
+ mockStat.mockResolvedValue({} as Stats);
378
+ mockIsWorkspaceTrusted.mockReturnValue({
379
+ isTrusted: undefined,
380
+ source: undefined,
381
+ });
382
+ mockPromptForConsentNonInteractive.mockResolvedValue(false);
383
+
384
+ await handleInstall({
385
+ source: path.join('/', 'evil', 'path'),
386
+ });
387
+
388
+ expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
389
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
390
+ expect(debugErrorSpy).toHaveBeenCalledWith(
391
+ expect.stringContaining('Installation aborted: Folder'),
392
+ );
393
+ expect(processSpy).toHaveBeenCalledWith(1);
394
+ });
395
+
396
+ it('should include discovery results in trust prompt', async () => {
397
+ mockInstallOrUpdateExtension.mockResolvedValue(
398
+ createMockExtension({
399
+ name: 'local-extension',
400
+ }),
401
+ );
402
+ mockStat.mockResolvedValue({} as Stats);
403
+ mockIsWorkspaceTrusted.mockReturnValue({
404
+ isTrusted: undefined,
405
+ source: undefined,
406
+ });
407
+ mockDiscover.mockResolvedValue({
408
+ commands: ['custom-cmd'],
409
+ mcps: [],
410
+ hooks: [],
411
+ skills: ['cool-skill'],
412
+ agents: ['cool-agent'],
413
+ settings: [],
414
+ securityWarnings: ['Security risk!'],
415
+ discoveryErrors: ['Read error'],
416
+ });
417
+ mockPromptForConsentNonInteractive.mockResolvedValue(true);
418
+ mockLoadTrustedFolders.mockReturnValue({
419
+ setValue: vi.fn(),
420
+ user: { path: '', config: {} },
421
+ errors: [],
422
+ rules: [],
423
+ isPathTrusted: vi.fn(),
424
+ });
425
+
426
+ await handleInstall({
427
+ source: '/untrusted/path',
428
+ });
429
+
430
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
431
+ expect.stringContaining('This folder contains:'),
432
+ false,
433
+ );
434
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
435
+ expect.stringContaining('custom-cmd'),
436
+ false,
437
+ );
438
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
439
+ expect.stringContaining('cool-skill'),
440
+ false,
441
+ );
442
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
443
+ expect.stringContaining('cool-agent'),
444
+ false,
445
+ );
446
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
447
+ expect.stringContaining('Security Warnings:'),
448
+ false,
449
+ );
450
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
451
+ expect.stringContaining('Security risk!'),
452
+ false,
453
+ );
454
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
455
+ expect.stringContaining('Discovery Errors:'),
456
+ false,
457
+ );
458
+ expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
459
+ expect.stringContaining('Read error'),
460
+ false,
461
+ );
462
+ });
463
+ });
464
+ // Implementation completed.
packages/cli/src/commands/extensions/install.ts ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { CommandModule } from 'yargs';
8
+ import * as path from 'node:path';
9
+ import chalk from 'chalk';
10
+ import {
11
+ debugLogger,
12
+ FolderTrustDiscoveryService,
13
+ getRealPath,
14
+ getErrorMessage,
15
+ } from '@google/gemini-cli-core';
16
+ import {
17
+ INSTALL_WARNING_MESSAGE,
18
+ promptForConsentNonInteractive,
19
+ requestConsentNonInteractive,
20
+ } from '../../config/extensions/consent.js';
21
+ import {
22
+ ExtensionManager,
23
+ inferInstallMetadata,
24
+ } from '../../config/extension-manager.js';
25
+ import { loadSettings } from '../../config/settings.js';
26
+ import {
27
+ isWorkspaceTrusted,
28
+ loadTrustedFolders,
29
+ TrustLevel,
30
+ } from '../../config/trustedFolders.js';
31
+ import { promptForSetting } from '../../config/extensions/extensionSettings.js';
32
+ import { exitCli } from '../utils.js';
33
+
34
+ interface InstallArgs {
35
+ source: string;
36
+ ref?: string;
37
+ autoUpdate?: boolean;
38
+ allowPreRelease?: boolean;
39
+ consent?: boolean;
40
+ skipSettings?: boolean;
41
+ }
42
+
43
+ export async function handleInstall(args: InstallArgs) {
44
+ try {
45
+ const { source } = args;
46
+ const installMetadata = await inferInstallMetadata(source, {
47
+ ref: args.ref,
48
+ autoUpdate: args.autoUpdate,
49
+ allowPreRelease: args.allowPreRelease,
50
+ });
51
+
52
+ const workspaceDir = process.cwd();
53
+ const settings = loadSettings(workspaceDir).merged;
54
+
55
+ if (installMetadata.type === 'local' || installMetadata.type === 'link') {
56
+ const absolutePath = path.resolve(source);
57
+ const realPath = getRealPath(absolutePath);
58
+ installMetadata.source = absolutePath;
59
+ const trustResult = isWorkspaceTrusted(settings, absolutePath);
60
+ if (trustResult.isTrusted !== true) {
61
+ const discoveryResults =
62
+ await FolderTrustDiscoveryService.discover(realPath);
63
+
64
+ const hasDiscovery =
65
+ discoveryResults.commands.length > 0 ||
66
+ discoveryResults.mcps.length > 0 ||
67
+ discoveryResults.hooks.length > 0 ||
68
+ discoveryResults.skills.length > 0 ||
69
+ discoveryResults.settings.length > 0;
70
+
71
+ const promptLines = [
72
+ '',
73
+ chalk.bold('Do you trust the files in this folder?'),
74
+ '',
75
+ `The extension source at "${absolutePath}" is not trusted.`,
76
+ '',
77
+ 'Trusting a folder allows Gemini CLI to load its local configurations,',
78
+ 'including custom commands, hooks, MCP servers, agent skills, and',
79
+ 'settings. These configurations could execute code on your behalf or',
80
+ 'change the behavior of the CLI.',
81
+ '',
82
+ ];
83
+
84
+ if (discoveryResults.discoveryErrors.length > 0) {
85
+ promptLines.push(chalk.red('❌ Discovery Errors:'));
86
+ for (const error of discoveryResults.discoveryErrors) {
87
+ promptLines.push(chalk.red(` • ${error}`));
88
+ }
89
+ promptLines.push('');
90
+ }
91
+
92
+ if (discoveryResults.securityWarnings.length > 0) {
93
+ promptLines.push(chalk.yellow('⚠️ Security Warnings:'));
94
+ for (const warning of discoveryResults.securityWarnings) {
95
+ promptLines.push(chalk.yellow(` • ${warning}`));
96
+ }
97
+ promptLines.push('');
98
+ }
99
+
100
+ if (hasDiscovery) {
101
+ promptLines.push(chalk.bold('This folder contains:'));
102
+ const groups = [
103
+ { label: 'Commands', items: discoveryResults.commands ?? [] },
104
+ { label: 'MCP Servers', items: discoveryResults.mcps ?? [] },
105
+ { label: 'Hooks', items: discoveryResults.hooks ?? [] },
106
+ { label: 'Skills', items: discoveryResults.skills ?? [] },
107
+ { label: 'Agents', items: discoveryResults.agents ?? [] },
108
+ {
109
+ label: 'Setting overrides',
110
+ items: discoveryResults.settings ?? [],
111
+ },
112
+ ].filter((g) => g.items.length > 0);
113
+
114
+ for (const group of groups) {
115
+ promptLines.push(
116
+ ` • ${chalk.bold(group.label)} (${group.items.length}):`,
117
+ );
118
+ for (const item of group.items) {
119
+ promptLines.push(` - ${item}`);
120
+ }
121
+ }
122
+ promptLines.push('');
123
+ }
124
+
125
+ promptLines.push(
126
+ chalk.yellow(
127
+ 'Do you want to trust this folder and continue with the installation? [y/N]: ',
128
+ ),
129
+ );
130
+
131
+ const confirmed = await promptForConsentNonInteractive(
132
+ promptLines.join('\n'),
133
+ false,
134
+ );
135
+ if (confirmed) {
136
+ const trustedFolders = loadTrustedFolders();
137
+ await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER);
138
+ } else {
139
+ throw new Error(
140
+ `Installation aborted: Folder "${absolutePath}" is not trusted.`,
141
+ );
142
+ }
143
+ }
144
+ }
145
+
146
+ const requestConsent = args.consent
147
+ ? () => Promise.resolve(true)
148
+ : requestConsentNonInteractive;
149
+ if (args.consent) {
150
+ debugLogger.log('You have consented to the following:');
151
+ debugLogger.log(INSTALL_WARNING_MESSAGE);
152
+ }
153
+
154
+ const extensionManager = new ExtensionManager({
155
+ workspaceDir,
156
+ requestConsent,
157
+ requestSetting: args.skipSettings ? null : promptForSetting,
158
+ settings,
159
+ });
160
+ await extensionManager.loadExtensions();
161
+ const extension =
162
+ await extensionManager.installOrUpdateExtension(installMetadata);
163
+ debugLogger.log(
164
+ `Extension "${extension.name}" installed successfully and enabled.`,
165
+ );
166
+ } catch (error) {
167
+ debugLogger.error(getErrorMessage(error));
168
+ process.exit(1);
169
+ }
170
+ }
171
+
172
+ export const installCommand: CommandModule = {
173
+ command: 'install <source> [--auto-update] [--pre-release]',
174
+ describe: 'Installs an extension from a git repository URL or a local path.',
175
+ builder: (yargs) =>
176
+ yargs
177
+ .positional('source', {
178
+ describe: 'The github URL or local path of the extension to install.',
179
+ type: 'string',
180
+ demandOption: true,
181
+ })
182
+ .option('ref', {
183
+ describe: 'The git ref to install from.',
184
+ type: 'string',
185
+ })
186
+ .option('auto-update', {
187
+ describe: 'Enable auto-update for this extension.',
188
+ type: 'boolean',
189
+ })
190
+ .option('pre-release', {
191
+ describe: 'Enable pre-release versions for this extension.',
192
+ type: 'boolean',
193
+ })
194
+ .option('consent', {
195
+ describe:
196
+ 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
197
+ type: 'boolean',
198
+ default: false,
199
+ })
200
+ .option('skip-settings', {
201
+ describe: 'Skip the configuration on install process.',
202
+ type: 'boolean',
203
+ default: false,
204
+ })
205
+ .check((argv) => {
206
+ if (!argv.source) {
207
+ throw new Error('The source argument must be provided.');
208
+ }
209
+ return true;
210
+ }),
211
+ handler: async (argv) => {
212
+ await handleInstall({
213
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
214
+ source: argv['source'] as string,
215
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
216
+ ref: argv['ref'] as string | undefined,
217
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
218
+ autoUpdate: argv['auto-update'] as boolean | undefined,
219
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
220
+ allowPreRelease: argv['pre-release'] as boolean | undefined,
221
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
222
+ consent: argv['consent'] as boolean | undefined,
223
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
224
+ skipSettings: argv['skip-settings'] as boolean | undefined,
225
+ });
226
+ await exitCli();
227
+ },
228
+ };
packages/cli/src/commands/extensions/link.test.ts ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ vi,
9
+ describe,
10
+ it,
11
+ expect,
12
+ beforeEach,
13
+ afterEach,
14
+ type Mock,
15
+ } from 'vitest';
16
+ import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
17
+ import { type Argv } from 'yargs';
18
+ import { handleLink, linkCommand } from './link.js';
19
+ import { ExtensionManager } from '../../config/extension-manager.js';
20
+ import { loadSettings, type LoadedSettings } from '../../config/settings.js';
21
+
22
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
23
+ const { mockCoreDebugLogger } = await import(
24
+ '../../test-utils/mockDebugLogger.js'
25
+ );
26
+ const actual =
27
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
28
+ const mocked = mockCoreDebugLogger(actual, { stripAnsi: true });
29
+ return { ...mocked, getErrorMessage: vi.fn() };
30
+ });
31
+
32
+ vi.mock('../../config/extension-manager.js');
33
+ vi.mock('../../config/settings.js');
34
+ vi.mock('../../config/extensions/consent.js', () => ({
35
+ requestConsentNonInteractive: vi.fn(),
36
+ }));
37
+ vi.mock('../../config/extensions/extensionSettings.js', () => ({
38
+ promptForSetting: vi.fn(),
39
+ }));
40
+ vi.mock('../utils.js', () => ({
41
+ exitCli: vi.fn(),
42
+ }));
43
+
44
+ describe('extensions link command', () => {
45
+ const mockLoadSettings = vi.mocked(loadSettings);
46
+ const mockGetErrorMessage = vi.mocked(getErrorMessage);
47
+ const mockExtensionManager = vi.mocked(ExtensionManager);
48
+
49
+ beforeEach(async () => {
50
+ vi.clearAllMocks();
51
+ mockLoadSettings.mockReturnValue({
52
+ merged: {},
53
+ } as unknown as LoadedSettings);
54
+ mockExtensionManager.prototype.loadExtensions = vi
55
+ .fn()
56
+ .mockResolvedValue(undefined);
57
+ mockExtensionManager.prototype.installOrUpdateExtension = vi
58
+ .fn()
59
+ .mockResolvedValue({ name: 'my-linked-extension' });
60
+ });
61
+
62
+ afterEach(() => {
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ describe('handleLink', () => {
67
+ it('should link an extension from a local path', async () => {
68
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
69
+ await handleLink({ path: '/local/path/to/extension' });
70
+
71
+ expect(mockExtensionManager).toHaveBeenCalledWith(
72
+ expect.objectContaining({
73
+ workspaceDir: '/test/dir',
74
+ }),
75
+ );
76
+ expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
77
+ expect(
78
+ mockExtensionManager.prototype.installOrUpdateExtension,
79
+ ).toHaveBeenCalledWith({
80
+ source: '/local/path/to/extension',
81
+ type: 'link',
82
+ });
83
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
84
+ 'log',
85
+ 'Extension "my-linked-extension" linked successfully and enabled.',
86
+ );
87
+ mockCwd.mockRestore();
88
+ });
89
+
90
+ it('should log an error message and exit with code 1 when linking fails', async () => {
91
+ const mockProcessExit = vi
92
+ .spyOn(process, 'exit')
93
+ .mockImplementation((() => {}) as (
94
+ code?: string | number | null | undefined,
95
+ ) => never);
96
+ const error = new Error('Link failed');
97
+ (
98
+ mockExtensionManager.prototype.installOrUpdateExtension as Mock
99
+ ).mockRejectedValue(error);
100
+ mockGetErrorMessage.mockReturnValue('Link failed message');
101
+
102
+ await handleLink({ path: '/local/path/to/extension' });
103
+
104
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
105
+ 'error',
106
+ 'Link failed message',
107
+ );
108
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
109
+ mockProcessExit.mockRestore();
110
+ });
111
+ });
112
+
113
+ describe('linkCommand', () => {
114
+ const command = linkCommand;
115
+
116
+ it('should have correct command and describe', () => {
117
+ expect(command.command).toBe('link <path>');
118
+ expect(command.describe).toBe(
119
+ 'Links an extension from a local path. Updates made to the local path will always be reflected.',
120
+ );
121
+ });
122
+
123
+ describe('builder', () => {
124
+ interface MockYargs {
125
+ positional: Mock;
126
+ option: Mock;
127
+ check: Mock;
128
+ }
129
+
130
+ let yargsMock: MockYargs;
131
+ beforeEach(() => {
132
+ yargsMock = {
133
+ positional: vi.fn().mockReturnThis(),
134
+ option: vi.fn().mockReturnThis(),
135
+ check: vi.fn().mockReturnThis(),
136
+ };
137
+ });
138
+
139
+ it('should configure positional argument', () => {
140
+ (command.builder as (yargs: Argv) => Argv)(
141
+ yargsMock as unknown as Argv,
142
+ );
143
+ expect(yargsMock.positional).toHaveBeenCalledWith('path', {
144
+ describe: 'The name of the extension to link.',
145
+ type: 'string',
146
+ });
147
+ expect(yargsMock.option).toHaveBeenCalledWith('consent', {
148
+ describe:
149
+ 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
150
+ type: 'boolean',
151
+ default: false,
152
+ });
153
+ expect(yargsMock.check).toHaveBeenCalled();
154
+ });
155
+ });
156
+
157
+ it('handler should call handleLink', async () => {
158
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
159
+ interface TestArgv {
160
+ path: string;
161
+ [key: string]: unknown;
162
+ }
163
+ const argv: TestArgv = {
164
+ path: '/local/path/to/extension',
165
+ _: [],
166
+ $0: '',
167
+ };
168
+ await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
169
+ argv,
170
+ );
171
+
172
+ expect(
173
+ mockExtensionManager.prototype.installOrUpdateExtension,
174
+ ).toHaveBeenCalledWith({
175
+ source: '/local/path/to/extension',
176
+ type: 'link',
177
+ });
178
+ mockCwd.mockRestore();
179
+ });
180
+ });
181
+ });
packages/cli/src/commands/extensions/link.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { CommandModule } from 'yargs';
8
+ import chalk from 'chalk';
9
+ import {
10
+ debugLogger,
11
+ getErrorMessage,
12
+ type ExtensionInstallMetadata,
13
+ } from '@google/gemini-cli-core';
14
+
15
+ import {
16
+ INSTALL_WARNING_MESSAGE,
17
+ requestConsentNonInteractive,
18
+ } from '../../config/extensions/consent.js';
19
+ import { ExtensionManager } from '../../config/extension-manager.js';
20
+ import { loadSettings } from '../../config/settings.js';
21
+ import { promptForSetting } from '../../config/extensions/extensionSettings.js';
22
+ import { exitCli } from '../utils.js';
23
+
24
+ interface InstallArgs {
25
+ path: string;
26
+ consent?: boolean;
27
+ }
28
+
29
+ export async function handleLink(args: InstallArgs) {
30
+ try {
31
+ const installMetadata: ExtensionInstallMetadata = {
32
+ source: args.path,
33
+ type: 'link',
34
+ };
35
+ const requestConsent = args.consent
36
+ ? () => Promise.resolve(true)
37
+ : requestConsentNonInteractive;
38
+ if (args.consent) {
39
+ debugLogger.log('You have consented to the following:');
40
+ debugLogger.log(INSTALL_WARNING_MESSAGE);
41
+ }
42
+ const workspaceDir = process.cwd();
43
+ const extensionManager = new ExtensionManager({
44
+ workspaceDir,
45
+ requestConsent,
46
+ requestSetting: promptForSetting,
47
+ settings: loadSettings(workspaceDir).merged,
48
+ });
49
+ await extensionManager.loadExtensions();
50
+ const extension =
51
+ await extensionManager.installOrUpdateExtension(installMetadata);
52
+ debugLogger.log(
53
+ chalk.green(
54
+ `Extension "${extension.name}" linked successfully and enabled.`,
55
+ ),
56
+ );
57
+ } catch (error) {
58
+ debugLogger.error(getErrorMessage(error));
59
+ process.exit(1);
60
+ }
61
+ }
62
+
63
+ export const linkCommand: CommandModule = {
64
+ command: 'link <path>',
65
+ describe:
66
+ 'Links an extension from a local path. Updates made to the local path will always be reflected.',
67
+ builder: (yargs) =>
68
+ yargs
69
+ .positional('path', {
70
+ describe: 'The name of the extension to link.',
71
+ type: 'string',
72
+ })
73
+ .option('consent', {
74
+ describe:
75
+ 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.',
76
+ type: 'boolean',
77
+ default: false,
78
+ })
79
+ .check((_) => true),
80
+ handler: async (argv) => {
81
+ await handleLink({
82
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
83
+ path: argv['path'] as string,
84
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
85
+ consent: argv['consent'] as boolean | undefined,
86
+ });
87
+ await exitCli();
88
+ },
89
+ };
packages/cli/src/commands/extensions/list.test.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
9
+ import { handleList, listCommand } from './list.js';
10
+ import { ExtensionManager } from '../../config/extension-manager.js';
11
+ import { loadSettings, type LoadedSettings } from '../../config/settings.js';
12
+
13
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
14
+ const { mockCoreDebugLogger } = await import(
15
+ '../../test-utils/mockDebugLogger.js'
16
+ );
17
+ const actual =
18
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
19
+ const mocked = mockCoreDebugLogger(actual, { stripAnsi: false });
20
+ return { ...mocked, getErrorMessage: vi.fn() };
21
+ });
22
+
23
+ vi.mock('../../config/extension-manager.js');
24
+ vi.mock('../../config/settings.js');
25
+ vi.mock('../../config/extensions/consent.js', () => ({
26
+ requestConsentNonInteractive: vi.fn(),
27
+ }));
28
+ vi.mock('../../config/extensions/extensionSettings.js', () => ({
29
+ promptForSetting: vi.fn(),
30
+ }));
31
+ vi.mock('../utils.js', () => ({
32
+ exitCli: vi.fn(),
33
+ }));
34
+
35
+ describe('extensions list command', () => {
36
+ const mockLoadSettings = vi.mocked(loadSettings);
37
+ const mockGetErrorMessage = vi.mocked(getErrorMessage);
38
+ const mockExtensionManager = vi.mocked(ExtensionManager);
39
+
40
+ beforeEach(async () => {
41
+ vi.clearAllMocks();
42
+ mockLoadSettings.mockReturnValue({
43
+ merged: {},
44
+ } as unknown as LoadedSettings);
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.restoreAllMocks();
49
+ });
50
+
51
+ describe('handleList', () => {
52
+ it('should log a message if no extensions are installed', async () => {
53
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
54
+ mockExtensionManager.prototype.loadExtensions = vi
55
+ .fn()
56
+ .mockResolvedValue([]);
57
+ await handleList();
58
+
59
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
60
+ 'log',
61
+ 'No extensions installed.',
62
+ );
63
+ mockCwd.mockRestore();
64
+ });
65
+
66
+ it('should output empty JSON array if no extensions are installed and output-format is json', async () => {
67
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
68
+ mockExtensionManager.prototype.loadExtensions = vi
69
+ .fn()
70
+ .mockResolvedValue([]);
71
+ await handleList({ outputFormat: 'json' });
72
+
73
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith('log', '[]');
74
+ mockCwd.mockRestore();
75
+ });
76
+
77
+ it('should list all installed extensions', async () => {
78
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
79
+ const extensions = [
80
+ { name: 'ext1', version: '1.0.0' },
81
+ { name: 'ext2', version: '2.0.0' },
82
+ ];
83
+ mockExtensionManager.prototype.loadExtensions = vi
84
+ .fn()
85
+ .mockResolvedValue(extensions);
86
+ mockExtensionManager.prototype.toOutputString = vi.fn(
87
+ (ext) => `${ext.name}@${ext.version}`,
88
+ );
89
+ await handleList();
90
+
91
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
92
+ 'log',
93
+ 'ext1@1.0.0\n\next2@2.0.0',
94
+ );
95
+ mockCwd.mockRestore();
96
+ });
97
+
98
+ it('should list all installed extensions in JSON format', async () => {
99
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
100
+ const extensions = [
101
+ { name: 'ext1', version: '1.0.0' },
102
+ { name: 'ext2', version: '2.0.0' },
103
+ ];
104
+ mockExtensionManager.prototype.loadExtensions = vi
105
+ .fn()
106
+ .mockResolvedValue(extensions);
107
+ await handleList({ outputFormat: 'json' });
108
+
109
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
110
+ 'log',
111
+ JSON.stringify(extensions, null, 2),
112
+ );
113
+ mockCwd.mockRestore();
114
+ });
115
+
116
+ it('should log an error message and exit with code 1 when listing fails', async () => {
117
+ const mockProcessExit = vi
118
+ .spyOn(process, 'exit')
119
+ .mockImplementation((() => {}) as (
120
+ code?: string | number | null | undefined,
121
+ ) => never);
122
+ const error = new Error('List failed');
123
+ mockExtensionManager.prototype.loadExtensions = vi
124
+ .fn()
125
+ .mockRejectedValue(error);
126
+ mockGetErrorMessage.mockReturnValue('List failed message');
127
+
128
+ await handleList();
129
+
130
+ expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
131
+ 'error',
132
+ 'List failed message',
133
+ );
134
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
135
+ mockProcessExit.mockRestore();
136
+ });
137
+ });
138
+
139
+ describe('listCommand', () => {
140
+ const command = listCommand;
141
+
142
+ it('should have correct command and describe', () => {
143
+ expect(command.command).toBe('list');
144
+ expect(command.describe).toBe('Lists installed extensions.');
145
+ });
146
+
147
+ it('builder should have output-format option', () => {
148
+ const mockYargs = {
149
+ option: vi.fn().mockReturnThis(),
150
+ };
151
+ (
152
+ command.builder as unknown as (
153
+ yargs: typeof mockYargs,
154
+ ) => typeof mockYargs
155
+ )(mockYargs);
156
+ expect(mockYargs.option).toHaveBeenCalledWith('output-format', {
157
+ alias: 'o',
158
+ type: 'string',
159
+ describe: 'The format of the CLI output.',
160
+ choices: ['text', 'json'],
161
+ default: 'text',
162
+ });
163
+ });
164
+
165
+ it('handler should call handleList with parsed arguments', async () => {
166
+ mockExtensionManager.prototype.loadExtensions = vi
167
+ .fn()
168
+ .mockResolvedValue([]);
169
+ await (
170
+ command.handler as unknown as (args: {
171
+ 'output-format': string;
172
+ }) => Promise<void>
173
+ )({
174
+ 'output-format': 'json',
175
+ });
176
+ expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
177
+ });
178
+ });
179
+ });
packages/cli/src/commands/extensions/list.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { CommandModule } from 'yargs';
8
+ import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
9
+ import { ExtensionManager } from '../../config/extension-manager.js';
10
+ import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
11
+ import { loadSettings } from '../../config/settings.js';
12
+ import { promptForSetting } from '../../config/extensions/extensionSettings.js';
13
+ import { exitCli } from '../utils.js';
14
+
15
+ export async function handleList(options?: { outputFormat?: 'text' | 'json' }) {
16
+ try {
17
+ const workspaceDir = process.cwd();
18
+ const extensionManager = new ExtensionManager({
19
+ workspaceDir,
20
+ requestConsent: requestConsentNonInteractive,
21
+ requestSetting: promptForSetting,
22
+ settings: loadSettings(workspaceDir).merged,
23
+ });
24
+ const extensions = await extensionManager.loadExtensions();
25
+ if (extensions.length === 0) {
26
+ if (options?.outputFormat === 'json') {
27
+ debugLogger.log('[]');
28
+ } else {
29
+ debugLogger.log('No extensions installed.');
30
+ }
31
+ return;
32
+ }
33
+
34
+ if (options?.outputFormat === 'json') {
35
+ debugLogger.log(JSON.stringify(extensions, null, 2));
36
+ } else {
37
+ debugLogger.log(
38
+ extensions
39
+ .map((extension, _): string =>
40
+ extensionManager.toOutputString(extension),
41
+ )
42
+ .join('\n\n'),
43
+ );
44
+ }
45
+ } catch (error) {
46
+ debugLogger.error(getErrorMessage(error));
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ export const listCommand: CommandModule = {
52
+ command: 'list',
53
+ describe: 'Lists installed extensions.',
54
+ builder: (yargs) =>
55
+ yargs.option('output-format', {
56
+ alias: 'o',
57
+ type: 'string',
58
+ describe: 'The format of the CLI output.',
59
+ choices: ['text', 'json'],
60
+ default: 'text',
61
+ }),
62
+ handler: async (argv) => {
63
+ await handleList({
64
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
65
+ outputFormat: argv['output-format'] as 'text' | 'json',
66
+ });
67
+ await exitCli();
68
+ },
69
+ };
packages/cli/src/commands/extensions/new.test.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import { newCommand } from './new.js';
9
+ import yargs from 'yargs';
10
+ import * as fsPromises from 'node:fs/promises';
11
+ import path from 'node:path';
12
+
13
+ vi.mock('node:fs/promises');
14
+ vi.mock('../utils.js', () => ({
15
+ exitCli: vi.fn(),
16
+ }));
17
+
18
+ const mockedFs = vi.mocked(fsPromises);
19
+
20
+ describe('extensions new command', () => {
21
+ beforeEach(() => {
22
+ vi.resetAllMocks();
23
+
24
+ const fakeFiles = [
25
+ { name: 'context', isDirectory: () => true },
26
+ { name: 'custom-commands', isDirectory: () => true },
27
+ { name: 'mcp-server', isDirectory: () => true },
28
+ ];
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ mockedFs.readdir.mockResolvedValue(fakeFiles as any);
31
+ });
32
+
33
+ it('should fail if no path is provided', async () => {
34
+ const parser = yargs([]).command(newCommand).fail(false).locale('en');
35
+ await expect(parser.parseAsync('new')).rejects.toThrow(
36
+ 'Not enough non-option arguments: got 0, need at least 1',
37
+ );
38
+ });
39
+
40
+ it('should create directory when no template is provided', async () => {
41
+ mockedFs.access.mockRejectedValue(new Error('ENOENT'));
42
+ mockedFs.mkdir.mockResolvedValue(undefined);
43
+
44
+ const parser = yargs([]).command(newCommand).fail(false);
45
+
46
+ await parser.parseAsync('new /some/path');
47
+
48
+ expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', {
49
+ recursive: true,
50
+ });
51
+ expect(mockedFs.cp).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('should create directory and copy files when path does not exist', async () => {
55
+ mockedFs.access.mockRejectedValue(new Error('ENOENT'));
56
+ mockedFs.mkdir.mockResolvedValue(undefined);
57
+ mockedFs.cp.mockResolvedValue(undefined);
58
+
59
+ const parser = yargs([]).command(newCommand).fail(false);
60
+
61
+ await parser.parseAsync('new /some/path context');
62
+
63
+ expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', {
64
+ recursive: true,
65
+ });
66
+ expect(mockedFs.cp).toHaveBeenCalledWith(
67
+ expect.stringContaining(path.normalize('context/context')),
68
+ path.normalize('/some/path/context'),
69
+ { recursive: true },
70
+ );
71
+ expect(mockedFs.cp).toHaveBeenCalledWith(
72
+ expect.stringContaining(path.normalize('context/custom-commands')),
73
+ path.normalize('/some/path/custom-commands'),
74
+ { recursive: true },
75
+ );
76
+ expect(mockedFs.cp).toHaveBeenCalledWith(
77
+ expect.stringContaining(path.normalize('context/mcp-server')),
78
+ path.normalize('/some/path/mcp-server'),
79
+ { recursive: true },
80
+ );
81
+ });
82
+
83
+ it('should throw an error if the path already exists', async () => {
84
+ mockedFs.access.mockResolvedValue(undefined);
85
+ const parser = yargs([]).command(newCommand).fail(false);
86
+
87
+ await expect(parser.parseAsync('new /some/path context')).rejects.toThrow(
88
+ 'Path already exists: /some/path',
89
+ );
90
+
91
+ expect(mockedFs.mkdir).not.toHaveBeenCalled();
92
+ expect(mockedFs.cp).not.toHaveBeenCalled();
93
+ });
94
+ });
packages/cli/src/commands/extensions/new.ts ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { access, cp, mkdir, readdir, writeFile } from 'node:fs/promises';
8
+ import { join, dirname, basename } from 'node:path';
9
+ import type { CommandModule } from 'yargs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { debugLogger } from '@google/gemini-cli-core';
12
+ import { exitCli } from '../utils.js';
13
+
14
+ interface NewArgs {
15
+ path: string;
16
+ template?: string;
17
+ }
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+
22
+ const EXAMPLES_PATH = join(__dirname, 'examples');
23
+
24
+ async function pathExists(path: string) {
25
+ try {
26
+ await access(path);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ async function createDirectory(path: string) {
34
+ if (await pathExists(path)) {
35
+ throw new Error(`Path already exists: ${path}`);
36
+ }
37
+ await mkdir(path, { recursive: true });
38
+ }
39
+
40
+ async function copyDirectory(template: string, path: string) {
41
+ await createDirectory(path);
42
+
43
+ const examplePath = join(EXAMPLES_PATH, template);
44
+ const entries = await readdir(examplePath, { withFileTypes: true });
45
+ for (const entry of entries) {
46
+ const srcPath = join(examplePath, entry.name);
47
+ const destPath = join(path, entry.name);
48
+ await cp(srcPath, destPath, { recursive: true });
49
+ }
50
+ }
51
+
52
+ async function handleNew(args: NewArgs) {
53
+ if (args.template) {
54
+ await copyDirectory(args.template, args.path);
55
+ debugLogger.log(
56
+ `Successfully created new extension from template "${args.template}" at ${args.path}.`,
57
+ );
58
+ } else {
59
+ await createDirectory(args.path);
60
+ const extensionName = basename(args.path);
61
+ const manifest = {
62
+ name: extensionName,
63
+ version: '1.0.0',
64
+ };
65
+ await writeFile(
66
+ join(args.path, 'gemini-extension.json'),
67
+ JSON.stringify(manifest, null, 2),
68
+ );
69
+ debugLogger.log(`Successfully created new extension at ${args.path}.`);
70
+ }
71
+ debugLogger.log(
72
+ `You can install this using "gemini extensions link ${args.path}" to test it out.`,
73
+ );
74
+ }
75
+
76
+ async function getBoilerplateChoices() {
77
+ const entries = await readdir(EXAMPLES_PATH, { withFileTypes: true });
78
+ return entries
79
+ .filter((entry) => entry.isDirectory())
80
+ .map((entry) => entry.name);
81
+ }
82
+
83
+ export const newCommand: CommandModule = {
84
+ command: 'new <path> [template]',
85
+ describe: 'Create a new extension from a boilerplate example.',
86
+ builder: async (yargs) => {
87
+ const choices = await getBoilerplateChoices();
88
+ return yargs
89
+ .positional('path', {
90
+ describe: 'The path to create the extension in.',
91
+ type: 'string',
92
+ })
93
+ .positional('template', {
94
+ describe: 'The boilerplate template to use.',
95
+ type: 'string',
96
+ choices,
97
+ });
98
+ },
99
+ handler: async (args) => {
100
+ await handleNew({
101
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
102
+ path: args['path'] as string,
103
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
104
+ template: args['template'] as string | undefined,
105
+ });
106
+ await exitCli();
107
+ },
108
+ };