fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences (#21171)

This commit is contained in:
Shreya Keshive
2026-03-05 14:57:28 -05:00
committed by GitHub
parent 9773a084c9
commit 0135b03c8a
24 changed files with 32 additions and 25 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { getAcpErrorMessage } from './acpErrors.js';
describe('getAcpErrorMessage', () => {
it('should return plain error message', () => {
expect(getAcpErrorMessage(new Error('plain error'))).toBe('plain error');
});
it('should parse simple JSON error response', () => {
const json = JSON.stringify({ error: { message: 'json error' } });
expect(getAcpErrorMessage(new Error(json))).toBe('json error');
});
it('should parse double-encoded JSON error response', () => {
const innerJson = JSON.stringify({ error: { message: 'nested error' } });
const outerJson = JSON.stringify({ error: { message: innerJson } });
expect(getAcpErrorMessage(new Error(outerJson))).toBe('nested error');
});
it('should parse array-style JSON error response', () => {
const json = JSON.stringify([{ error: { message: 'array error' } }]);
expect(getAcpErrorMessage(new Error(json))).toBe('array error');
});
it('should parse JSON with top-level message field', () => {
const json = JSON.stringify({ message: 'top-level message' });
expect(getAcpErrorMessage(new Error(json))).toBe('top-level message');
});
it('should handle JSON with trailing newline', () => {
const json = JSON.stringify({ error: { message: 'newline error' } }) + '\n';
expect(getAcpErrorMessage(new Error(json))).toBe('newline error');
});
it('should return original message if JSON parsing fails', () => {
const invalidJson = '{ not-json }';
expect(getAcpErrorMessage(new Error(invalidJson))).toBe(invalidJson);
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { getErrorMessage as getCoreErrorMessage } from '@google/gemini-cli-core';
/**
* Extracts a human-readable error message specifically for ACP (IDE) clients.
* This function recursively parses JSON error blobs that are common in
* Google API responses but ugly to display in an IDE's UI.
*/
export function getAcpErrorMessage(error: unknown): string {
const coreMessage = getCoreErrorMessage(error);
return extractRecursiveMessage(coreMessage);
}
function extractRecursiveMessage(input: string): string {
const trimmed = input.trim();
// Attempt to parse JSON error responses (common in Google API errors)
if (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = JSON.parse(trimmed);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const next =
parsed?.error?.message ||
parsed?.[0]?.error?.message ||
parsed?.message;
if (next && typeof next === 'string' && next !== input) {
return extractRecursiveMessage(next);
}
} catch {
// Fall back to original string if parsing fails
}
}
return input;
}
+291
View File
@@ -0,0 +1,291 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type Mocked,
type Mock,
} from 'vitest';
import { GeminiAgent } from './acpClient.js';
import * as acp from '@agentclientprotocol/sdk';
import {
ApprovalMode,
AuthType,
type Config,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/sessionUtils.js')>();
return {
...actual,
SessionSelector: vi.fn(),
convertSessionToHistoryFormats: vi.fn(),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
CoreToolCallStatus: {
Validating: 'validating',
Scheduled: 'scheduled',
Error: 'error',
Success: 'success',
Executing: 'executing',
Cancelled: 'cancelled',
AwaitingApproval: 'awaiting_approval',
},
LlmRole: {
MAIN: 'main',
SUBAGENT: 'subagent',
UTILITY_TOOL: 'utility_tool',
USER: 'user',
MODEL: 'model',
SYSTEM: 'system',
TOOL: 'tool',
},
convertSessionToClientHistory: vi.fn(),
};
});
describe('GeminiAgent Session Resume', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
let mockArgv: CliArgs;
let mockConnection: Mocked<acp.AgentSideConnection>;
let agent: GeminiAgent;
beforeEach(() => {
mockConfig = {
refreshAuth: vi.fn().mockResolvedValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getGeminiClient: vi.fn().mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
resumeChat: vi.fn().mockResolvedValue(undefined),
getChat: vi.fn().mockReturnValue({}),
}),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
mcpServers: {},
},
setValue: vi.fn(),
} as unknown as Mocked<LoadedSettings>;
mockArgv = {} as unknown as CliArgs;
mockConnection = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
} as unknown as Mocked<acp.AgentSideConnection>;
(loadCliConfig as Mock).mockResolvedValue(mockConfig);
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
});
it('should advertise loadSession capability', async () => {
const response = await agent.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
});
expect(response.agentCapabilities?.loadSession).toBe(true);
});
it('should load a session, resume chat, and stream all message types', async () => {
const sessionId = 'existing-session-id';
const sessionData = {
sessionId,
messages: [
{ type: 'user', content: [{ text: 'Hello' }] },
{
type: 'gemini',
content: [{ text: 'Hi there' }],
thoughts: [{ subject: 'Thinking', description: 'about greeting' }],
toolCalls: [
{
id: 'call-1',
name: 'test_tool',
displayName: 'Test Tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'Tool output',
},
],
},
{
type: 'gemini',
content: [{ text: 'Trying a write' }],
toolCalls: [
{
id: 'call-2',
name: 'write_file',
displayName: 'Write File',
status: CoreToolCallStatus.Error,
resultDisplay: 'Permission denied',
},
],
},
],
};
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
});
(SessionSelector as unknown as Mock).mockImplementation(() => ({
resolveSession: vi.fn().mockResolvedValue({
sessionData,
sessionPath: '/path/to/session.json',
}),
}));
const mockClientHistory = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
];
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
uiHistory: [],
});
(convertSessionToClientHistory as unknown as Mock).mockReturnValue(
mockClientHistory,
);
const response = await agent.loadSession({
sessionId,
cwd: '/tmp',
mcpServers: [],
});
expect(response).toEqual({
modes: {
availableModes: [
{
id: ApprovalMode.DEFAULT,
name: 'Default',
description: 'Prompts for approval',
},
{
id: ApprovalMode.AUTO_EDIT,
name: 'Auto Edit',
description: 'Auto-approves edit tools',
},
{
id: ApprovalMode.YOLO,
name: 'YOLO',
description: 'Auto-approves all tools',
},
],
currentModeId: ApprovalMode.DEFAULT,
},
models: {
availableModels: expect.any(Array) as unknown,
currentModelId: 'gemini-pro',
},
});
// Verify resumeChat received the correct arguments
expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
mockClientHistory,
expect.objectContaining({
conversation: sessionData,
filePath: '/path/to/session.json',
}),
);
await vi.waitFor(() => {
// User message
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'user_message_chunk',
content: expect.objectContaining({ text: 'Hello' }),
}),
}),
);
// Agent thought
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_thought_chunk',
content: expect.objectContaining({
text: '**Thinking**\nabout greeting',
}),
}),
}),
);
// Agent message
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: expect.objectContaining({ text: 'Hi there' }),
}),
}),
);
// Successful tool call → 'completed'
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-1',
status: 'completed',
title: 'Test Tool',
kind: 'read',
content: [
{
type: 'content',
content: { type: 'text', text: 'Tool output' },
},
],
}),
}),
);
// Failed tool call → 'failed'
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-2',
status: 'failed',
title: 'Write File',
kind: 'read',
}),
}),
);
});
});
});
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandHandler } from './commandHandler.js';
import { describe, it, expect } from 'vitest';
describe('CommandHandler', () => {
it('parses commands correctly', () => {
const handler = new CommandHandler();
// @ts-expect-error - testing private method
const parse = (query: string) => handler.parseSlashCommand(query);
const memShow = parse('/memory show');
expect(memShow.commandToExecute?.name).toBe('memory show');
expect(memShow.args).toBe('');
const memAdd = parse('/memory add hello world');
expect(memAdd.commandToExecute?.name).toBe('memory add');
expect(memAdd.args).toBe('hello world');
const extList = parse('/extensions list');
expect(extList.commandToExecute?.name).toBe('extensions list');
const init = parse('/init');
expect(init.commandToExecute?.name).toBe('init');
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Command, CommandContext } from './commands/types.js';
import { CommandRegistry } from './commands/commandRegistry.js';
import { MemoryCommand } from './commands/memory.js';
import { ExtensionsCommand } from './commands/extensions.js';
import { InitCommand } from './commands/init.js';
import { RestoreCommand } from './commands/restore.js';
export class CommandHandler {
private registry: CommandRegistry;
constructor() {
this.registry = CommandHandler.createRegistry();
}
private static createRegistry(): CommandRegistry {
const registry = new CommandRegistry();
registry.register(new MemoryCommand());
registry.register(new ExtensionsCommand());
registry.register(new InitCommand());
registry.register(new RestoreCommand());
return registry;
}
getAvailableCommands(): Array<{ name: string; description: string }> {
return this.registry.getAllCommands().map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
}
/**
* Parses and executes a command string if it matches a registered command.
* Returns true if a command was handled, false otherwise.
*/
async handleCommand(
commandText: string,
context: CommandContext,
): Promise<boolean> {
const { commandToExecute, args } = this.parseSlashCommand(commandText);
if (commandToExecute) {
await this.runCommand(commandToExecute, args, context);
return true;
}
return false;
}
private async runCommand(
commandToExecute: Command,
args: string,
context: CommandContext,
): Promise<void> {
try {
const result = await commandToExecute.execute(
context,
args ? args.split(/\s+/) : [],
);
let messageContent = '';
if (typeof result.data === 'string') {
messageContent = result.data;
} else if (
typeof result.data === 'object' &&
result.data !== null &&
'content' in result.data
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
messageContent = (result.data as Record<string, any>)[
'content'
] as string;
} else {
messageContent = JSON.stringify(result.data, null, 2);
}
await context.sendMessage(messageContent);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
await context.sendMessage(`Error: ${errorMessage}`);
}
}
/**
* Parses a raw slash command string into its matching headless command and arguments.
* Mirrors `packages/cli/src/utils/commands.ts` logic.
*/
private parseSlashCommand(query: string): {
commandToExecute: Command | undefined;
args: string;
} {
const trimmed = query.trim();
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p);
let currentCommands = this.registry.getAllCommands();
let commandToExecute: Command | undefined;
let pathIndex = 0;
for (const part of commandPath) {
const foundCommand = currentCommands.find((cmd) => {
const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
return (
cmd.name === part ||
cmd.name === expectedName ||
cmd.aliases?.includes(part) ||
cmd.aliases?.includes(expectedName)
);
});
if (foundCommand) {
commandToExecute = foundCommand;
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
} else {
break;
}
}
const args = parts.slice(pathIndex).join(' ');
return { commandToExecute, args };
}
}
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import type { Command } from './types.js';
export class CommandRegistry {
private readonly commands = new Map<string, Command>();
register(command: Command) {
if (this.commands.has(command.name)) {
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
return;
}
this.commands.set(command.name, command);
for (const subCommand of command.subCommands ?? []) {
this.register(subCommand);
}
}
get(commandName: string): Command | undefined {
return this.commands.get(commandName);
}
getAllCommands(): Command[] {
return [...this.commands.values()];
}
}
+428
View File
@@ -0,0 +1,428 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { listExtensions } from '@google/gemini-cli-core';
import { SettingScope } from '../../config/settings.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { getErrorMessage } from '../../utils/errors.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
import { stat } from 'node:fs/promises';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { Config } from '@google/gemini-cli-core';
export class ExtensionsCommand implements Command {
readonly name = 'extensions';
readonly description = 'Manage extensions.';
readonly subCommands = [
new ListExtensionsCommand(),
new ExploreExtensionsCommand(),
new EnableExtensionCommand(),
new DisableExtensionCommand(),
new InstallExtensionCommand(),
new LinkExtensionCommand(),
new UninstallExtensionCommand(),
new RestartExtensionCommand(),
new UpdateExtensionCommand(),
];
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ListExtensionsCommand().execute(context, _);
}
}
export class ListExtensionsCommand implements Command {
readonly name = 'extensions list';
readonly description = 'Lists all installed extensions.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensions = listExtensions(context.config);
const data = extensions.length ? extensions : 'No extensions installed.';
return { name: this.name, data };
}
}
export class ExploreExtensionsCommand implements Command {
readonly name = 'extensions explore';
readonly description = 'Explore available extensions.';
async execute(
_context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensionsUrl = 'https://geminicli.com/extensions/';
return {
name: this.name,
data: `View or install available extensions at ${extensionsUrl}`,
};
}
}
function getEnableDisableContext(
config: Config,
args: string[],
invocationName: string,
) {
const extensionManager = config.getExtensionLoader();
if (!(extensionManager instanceof ExtensionManager)) {
return {
error: `Cannot ${invocationName} extensions in this environment.`,
};
}
if (args.length === 0) {
return {
error: `Usage: /extensions ${invocationName} <extension> [--scope=<user|workspace|session>]`,
};
}
let scope = SettingScope.User;
if (args.includes('--scope=workspace') || args.includes('workspace')) {
scope = SettingScope.Workspace;
} else if (args.includes('--scope=session') || args.includes('session')) {
scope = SettingScope.Session;
}
const name = args.filter(
(a) =>
!a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
)[0];
let names: string[] = [];
if (name === '--all') {
let extensions = extensionManager.getExtensions();
if (invocationName === 'enable') {
extensions = extensions.filter((ext) => !ext.isActive);
}
if (invocationName === 'disable') {
extensions = extensions.filter((ext) => ext.isActive);
}
names = extensions.map((ext) => ext.name);
} else if (name) {
names = [name];
} else {
return { error: 'No extension name provided.' };
}
return { extensionManager, names, scope };
}
export class EnableExtensionCommand implements Command {
readonly name = 'extensions enable';
readonly description = 'Enable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.config,
args,
'enable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.enableExtension(name, scope);
output.push(`Extension "${name}" enabled for scope "${scope}".`);
const extension = extensionManager
.getExtensions()
.find((e) => e.name === name);
if (extension?.mcpServers) {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const mcpClientManager = context.config.getMcpClientManager();
const enabledServers = await mcpEnablementManager.autoEnableServers(
Object.keys(extension.mcpServers),
);
if (mcpClientManager && enabledServers.length > 0) {
const restartPromises = enabledServers.map((serverName) =>
mcpClientManager.restartServer(serverName).catch((error) => {
output.push(
`Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
);
}),
);
await Promise.all(restartPromises);
output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
}
}
} catch (e) {
output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class DisableExtensionCommand implements Command {
readonly name = 'extensions disable';
readonly description = 'Disable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.config,
args,
'disable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.disableExtension(name, scope);
output.push(`Extension "${name}" disabled for scope "${scope}".`);
} catch (e) {
output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class InstallExtensionCommand implements Command {
readonly name = 'extensions install';
readonly description = 'Install an extension from a git repo or local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot install extensions in this environment.',
};
}
const source = args.join(' ').trim();
if (!source) {
return { name: this.name, data: `Usage: /extensions install <source>` };
}
if (/[;&|`'"]/.test(source)) {
return {
name: this.name,
data: `Invalid source: contains disallowed characters.`,
};
}
try {
const installMetadata = await inferInstallMetadata(source);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
return {
name: this.name,
data: `Extension "${extension.name}" installed successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
};
}
}
}
export class LinkExtensionCommand implements Command {
readonly name = 'extensions link';
readonly description = 'Link an extension from a local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot link extensions in this environment.',
};
}
const sourceFilepath = args.join(' ').trim();
if (!sourceFilepath) {
return { name: this.name, data: `Usage: /extensions link <source>` };
}
try {
await stat(sourceFilepath);
} catch (_error) {
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
}
try {
const extension = await extensionLoader.installOrUpdateExtension({
source: sourceFilepath,
type: 'link',
});
return {
name: this.name,
data: `Extension "${extension.name}" linked successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to link extension: ${getErrorMessage(error)}`,
};
}
}
}
export class UninstallExtensionCommand implements Command {
readonly name = 'extensions uninstall';
readonly description = 'Uninstall an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot uninstall extensions in this environment.',
};
}
const name = args.join(' ').trim();
if (!name) {
return {
name: this.name,
data: `Usage: /extensions uninstall <extension-name>`,
};
}
try {
await extensionLoader.uninstallExtension(name, false);
return {
name: this.name,
data: `Extension "${name}" uninstalled successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to uninstall extension "${name}": ${getErrorMessage(error)}`,
};
}
}
}
export class RestartExtensionCommand implements Command {
readonly name = 'extensions restart';
readonly description = 'Restart an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot restart extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions restart <extension-names>|--all',
};
}
let extensionsToRestart = extensionLoader
.getExtensions()
.filter((e) => e.isActive);
if (names) {
extensionsToRestart = extensionsToRestart.filter((e) =>
names.includes(e.name),
);
}
if (extensionsToRestart.length === 0) {
return {
name: this.name,
data: 'No active extensions matched the request.',
};
}
const output: string[] = [];
for (const extension of extensionsToRestart) {
try {
await extensionLoader.restartExtension(extension);
output.push(`Restarted "${extension.name}".`);
} catch (e) {
output.push(
`Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
);
}
}
return { name: this.name, data: output.join('\n') };
}
}
export class UpdateExtensionCommand implements Command {
readonly name = 'extensions update';
readonly description = 'Update an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot update extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions update <extension-names>|--all',
};
}
return {
name: this.name,
data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
};
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { performInit } from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class InitCommand implements Command {
name = 'init';
description = 'Analyzes the project and creates a tailored GEMINI.md file';
requiresWorkspace = true;
async execute(
context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const targetDir = context.config.getTargetDir();
if (!targetDir) {
throw new Error('Command requires a workspace.');
}
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
const result = performInit(fs.existsSync(geminiMdPath));
switch (result.type) {
case 'message':
return {
name: this.name,
data: result,
};
case 'submit_prompt':
fs.writeFileSync(geminiMdPath, '', 'utf8');
if (typeof result.content !== 'string') {
throw new Error('Init command content must be a string.');
}
// Inform the user since we can't trigger the UI-based interactive agent loop here directly.
// We output the prompt text they can use to re-trigger the generation manually,
// or just seed the GEMINI.md file as we've done above.
return {
name: this.name,
data: {
type: 'message',
messageType: 'info',
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}`,
},
};
default:
throw new Error('Unknown result type from performInit');
}
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
addMemory,
listMemoryFiles,
refreshMemory,
showMemory,
} from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
};
export class MemoryCommand implements Command {
readonly name = 'memory';
readonly description = 'Manage memory.';
readonly subCommands = [
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
];
readonly requiresWorkspace = true;
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ShowMemoryCommand().execute(context, _);
}
}
export class ShowMemoryCommand implements Command {
readonly name = 'memory show';
readonly description = 'Shows the current memory contents.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = showMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class RefreshMemoryCommand implements Command {
readonly name = 'memory refresh';
readonly aliases = ['memory reload'];
readonly description = 'Refreshes the memory from the source.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = await refreshMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class ListMemoryCommand implements Command {
readonly name = 'memory list';
readonly description = 'Lists the paths of the GEMINI.md files in use.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = listMemoryFiles(context.config);
return { name: this.name, data: result.content };
}
}
export class AddMemoryCommand implements Command {
readonly name = 'memory add';
readonly description = 'Add content to the memory.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const textToAdd = args.join(' ').trim();
const result = addMemory(textToAdd);
if (result.type === 'message') {
return { name: this.name, data: result.content };
}
const toolRegistry = context.config.getToolRegistry();
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
const signal = abortController.signal;
await context.sendMessage(`Saving memory via ${result.toolName}...`);
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
});
await refreshMemory(context.config);
return {
name: this.name,
data: `Added memory: "${textToAdd}"`,
};
} else {
return {
name: this.name,
data: `Error: Tool ${result.toolName} not found.`,
};
}
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
getCheckpointInfoList,
getToolCallDataSchema,
isNodeError,
performRestore,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class RestoreCommand implements Command {
readonly name = 'restore';
readonly description =
'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';
readonly requiresWorkspace = true;
readonly subCommands = [new ListCheckpointsCommand()];
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const { config, git: gitService } = context;
const argsStr = args.join(' ');
try {
if (!argsStr) {
return await new ListCheckpointsCommand().execute(context);
}
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const selectedFile = argsStr.endsWith('.json')
? argsStr
: `${argsStr}.json`;
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
const filePath = path.join(checkpointDir, selectedFile);
let data: string;
try {
data = await fs.readFile(filePath, 'utf-8');
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return {
name: this.name,
data: `File not found: ${selectedFile}`,
};
}
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const toolCallData = JSON.parse(data);
const ToolCallDataSchema = getToolCallDataSchema();
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
if (!parseResult.success) {
return {
name: this.name,
data: 'Checkpoint file is invalid or corrupted.',
};
}
const restoreResultGenerator = performRestore(
parseResult.data,
gitService,
);
const restoreResult = [];
for await (const result of restoreResultGenerator) {
restoreResult.push(result);
}
// Format the result nicely since Zed just dumps data
const formattedResult = restoreResult
.map((r) => {
if (r.type === 'message') {
return `[${r.messageType.toUpperCase()}] ${r.content}`;
} else if (r.type === 'load_history') {
return `Loaded history with ${r.clientHistory.length} messages.`;
}
return `Restored: ${JSON.stringify(r)}`;
})
.join('\n');
return {
name: this.name,
data: formattedResult,
};
} catch (error) {
return {
name: this.name,
data: `An unexpected error occurred during restore: ${error}`,
};
}
}
}
export class ListCheckpointsCommand implements Command {
readonly name = 'restore list';
readonly description = 'Lists all available checkpoints.';
async execute(context: CommandContext): Promise<CommandExecutionResponse> {
const { config } = context;
try {
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
try {
await fs.mkdir(checkpointDir, { recursive: true });
} catch (_e) {
// Ignore
}
const files = await fs.readdir(checkpointDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
if (jsonFiles.length === 0) {
return { name: this.name, data: 'No checkpoints found.' };
}
const checkpointFiles = new Map<string, string>();
for (const file of jsonFiles) {
const filePath = path.join(checkpointDir, file);
const data = await fs.readFile(filePath, 'utf-8');
checkpointFiles.set(file, data);
}
const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
const formatted = checkpointInfoList
.map((info) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const i = info as Record<string, any>;
const fileName = String(i['fileName'] || 'Unknown');
const toolName = String(i['toolName'] || 'Unknown');
const status = String(i['status'] || 'Unknown');
const timestamp = new Date(
Number(i['timestamp']) || 0,
).toLocaleString();
return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
})
.join('\n');
return {
name: this.name,
data: `Available Checkpoints:\n${formatted}`,
};
} catch (_error) {
return {
name: this.name,
data: 'An unexpected error occurred while listing checkpoints.',
};
}
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, GitService } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
export interface CommandContext {
config: Config;
settings: LoadedSettings;
git?: GitService;
sendMessage: (text: string) => Promise<void>;
}
export interface CommandArgument {
readonly name: string;
readonly description: string;
readonly isRequired?: boolean;
}
export interface Command {
readonly name: string;
readonly aliases?: string[];
readonly description: string;
readonly arguments?: CommandArgument[];
readonly subCommands?: Command[];
readonly requiresWorkspace?: boolean;
execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse>;
}
export interface CommandExecutionResponse {
readonly name: string;
readonly data: unknown;
}
@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { AcpFileSystemService } from './fileSystemService.js';
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
import type { FileSystemService } from '@google/gemini-cli-core';
describe('AcpFileSystemService', () => {
let mockConnection: Mocked<AgentSideConnection>;
let mockFallback: Mocked<FileSystemService>;
let service: AcpFileSystemService;
beforeEach(() => {
mockConnection = {
requestPermission: vi.fn(),
sessionUpdate: vi.fn(),
writeTextFile: vi.fn(),
readTextFile: vi.fn(),
} as unknown as Mocked<AgentSideConnection>;
mockFallback = {
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
};
});
describe('readTextFile', () => {
it.each([
{
capability: true,
desc: 'connection if capability exists',
setup: () => {
mockConnection.readTextFile.mockResolvedValue({ content: 'content' });
},
verify: () => {
expect(mockConnection.readTextFile).toHaveBeenCalledWith({
path: '/path/to/file',
sessionId: 'session-1',
});
expect(mockFallback.readTextFile).not.toHaveBeenCalled();
},
},
{
capability: false,
desc: 'fallback if capability missing',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/path/to/file',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, setup, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: capability, writeTextFile: true },
mockFallback,
);
setup();
const result = await service.readTextFile('/path/to/file');
expect(result).toBe('content');
verify();
});
});
describe('writeTextFile', () => {
it.each([
{
capability: true,
desc: 'connection if capability exists',
verify: () => {
expect(mockConnection.writeTextFile).toHaveBeenCalledWith({
path: '/path/to/file',
content: 'content',
sessionId: 'session-1',
});
expect(mockFallback.writeTextFile).not.toHaveBeenCalled();
},
},
{
capability: false,
desc: 'fallback if capability missing',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/path/to/file',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ writeTextFile: capability, readTextFile: true },
mockFallback,
);
await service.writeTextFile('/path/to/file', 'content');
verify();
});
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { FileSystemService } from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
/**
* ACP client-based implementation of FileSystemService
*/
export class AcpFileSystemService implements FileSystemService {
constructor(
private readonly connection: acp.AgentSideConnection,
private readonly sessionId: string,
private readonly capabilities: acp.FileSystemCapability,
private readonly fallback: FileSystemService,
) {}
async readTextFile(filePath: string): Promise<string> {
if (!this.capabilities.readTextFile) {
return this.fallback.readTextFile(filePath);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response = await this.connection.readTextFile({
path: filePath,
sessionId: this.sessionId,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.content;
}
async writeTextFile(filePath: string, content: string): Promise<void> {
if (!this.capabilities.writeTextFile) {
return this.fallback.writeTextFile(filePath, content);
}
await this.connection.writeTextFile({
path: filePath,
content,
sessionId: this.sessionId,
});
}
}