Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap +35 -0
- packages/cli/src/__snapshots__/nonInteractiveCliAgentSession.test.ts.snap +35 -0
- packages/cli/src/acp/README.md +81 -0
- packages/cli/src/acp/acpCommandHandler.test.ts +35 -0
- packages/cli/src/acp/acpCommandHandler.ts +138 -0
- packages/cli/src/acp/acpErrors.test.ts +45 -0
- packages/cli/src/acp/acpErrors.ts +44 -0
- packages/cli/src/acp/acpFileSystemService.test.ts +236 -0
- packages/cli/src/acp/acpFileSystemService.ts +88 -0
- packages/cli/src/acp/acpResume.test.ts +313 -0
- packages/cli/src/acp/acpRpcDispatcher.test.ts +339 -0
- packages/cli/src/acp/acpRpcDispatcher.ts +236 -0
- packages/cli/src/acp/acpSession.test.ts +1144 -0
- packages/cli/src/acp/acpSession.ts +1527 -0
- packages/cli/src/acp/acpSessionManager.test.ts +382 -0
- packages/cli/src/acp/acpSessionManager.ts +343 -0
- packages/cli/src/acp/acpStdioTransport.ts +35 -0
- packages/cli/src/acp/acpUtils.ts +365 -0
- packages/cli/src/acp/commands/about.ts +74 -0
- packages/cli/src/acp/commands/commandRegistry.ts +33 -0
- packages/cli/src/acp/commands/extensions.test.ts +100 -0
- packages/cli/src/acp/commands/extensions.ts +448 -0
- packages/cli/src/acp/commands/help.test.ts +53 -0
- packages/cli/src/acp/commands/help.ts +50 -0
- packages/cli/src/acp/commands/init.ts +62 -0
- packages/cli/src/acp/commands/memory.ts +142 -0
- packages/cli/src/acp/commands/restore.test.ts +244 -0
- packages/cli/src/acp/commands/restore.ts +179 -0
- packages/cli/src/acp/commands/types.ts +40 -0
- packages/cli/src/commands/extensions.test.tsx +92 -0
- packages/cli/src/commands/extensions.tsx +47 -0
- packages/cli/src/commands/extensions/configure.test.ts +290 -0
- packages/cli/src/commands/extensions/configure.ts +98 -0
- packages/cli/src/commands/extensions/disable.test.ts +250 -0
- packages/cli/src/commands/extensions/disable.ts +88 -0
- packages/cli/src/commands/extensions/enable.test.ts +280 -0
- packages/cli/src/commands/extensions/enable.ts +115 -0
- packages/cli/src/commands/extensions/examples/exclude-tools/.gitignore +26 -0
- packages/cli/src/commands/extensions/examples/exclude-tools/gemini-extension.json +5 -0
- packages/cli/src/commands/extensions/examples/mcp-server/.gitignore +26 -0
- packages/cli/src/commands/extensions/examples/mcp-server/README.md +35 -0
- packages/cli/src/commands/extensions/examples/mcp-server/gemini-extension.json +11 -0
- packages/cli/src/commands/extensions/install.test.ts +464 -0
- packages/cli/src/commands/extensions/install.ts +228 -0
- packages/cli/src/commands/extensions/link.test.ts +181 -0
- packages/cli/src/commands/extensions/link.ts +89 -0
- packages/cli/src/commands/extensions/list.test.ts +179 -0
- packages/cli/src/commands/extensions/list.ts +69 -0
- packages/cli/src/commands/extensions/new.test.ts +94 -0
- 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 |
+
};
|