mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-11 10:30:46 -07:00
Merge branch 'main' into show_thinking
This commit is contained in:
@@ -124,6 +124,12 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
createPolicyEngineConfig: vi.fn(async () => ({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
|
||||
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2317,3 +2323,92 @@ describe('Telemetry configuration via environment variables', () => {
|
||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PolicyEngine nonInteractive wiring', () => {
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should set nonInteractive to true in one-shot mode', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig({}, 'test-session', argv);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(
|
||||
(config.getPolicyEngine() as unknown as { nonInteractive: boolean })
|
||||
.nonInteractive,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should set nonInteractive to false in interactive mode', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig({}, 'test-session', argv);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(
|
||||
(config.getPolicyEngine() as unknown as { nonInteractive: boolean })
|
||||
.nonInteractive,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Policy Engine Integration in loadCliConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => {
|
||||
process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool'];
|
||||
const settings: Settings = { tools: { allowed: ['settings-tool'] } };
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
allowed: expect.arrayContaining(['cli-tool']),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => {
|
||||
process.stdin.isTTY = false; // Non-interactive to trigger default excludes
|
||||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const settings: Settings = { tools: { exclude: ['settings-exclude'] } };
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
// In non-interactive mode, ShellTool, etc. are excluded
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
exclude: expect.arrayContaining([SHELL_TOOL_NAME]),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,6 @@ export interface CliArgs {
|
||||
deleteSession: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
screenReader: boolean | undefined;
|
||||
useSmartEdit: boolean | undefined;
|
||||
useWriteTodos: boolean | undefined;
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
@@ -537,20 +536,16 @@ export async function loadCliConfig(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
settings,
|
||||
approvalMode,
|
||||
);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
|
||||
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
|
||||
const hasQuery = !!argv.query;
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.experimentalAcp ||
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive) {
|
||||
@@ -590,6 +585,26 @@ export async function loadCliConfig(
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
);
|
||||
|
||||
// Create a settings object that includes CLI overrides for policy generation
|
||||
const effectiveSettings: Settings = {
|
||||
...settings,
|
||||
tools: {
|
||||
...settings.tools,
|
||||
allowed: allowedTools,
|
||||
exclude: excludeTools,
|
||||
},
|
||||
mcp: {
|
||||
...settings.mcp,
|
||||
allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
|
||||
},
|
||||
};
|
||||
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
effectiveSettings,
|
||||
approvalMode,
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
const defaultModel = settings.general?.previewFeatures
|
||||
? PREVIEW_GEMINI_MODEL_AUTO
|
||||
: DEFAULT_GEMINI_MODEL_AUTO;
|
||||
@@ -689,7 +704,6 @@ export async function loadCliConfig(
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
output: {
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { loadSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionManager skills validation', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let userExtensionsDir: string;
|
||||
let extensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-skills-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-skills-test-workspace-'),
|
||||
);
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
mockHomedir.mockReturnValue(tempHomeDir);
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn().mockResolvedValue(''),
|
||||
settings: loadSettings(tempWorkspaceDir).merged,
|
||||
});
|
||||
vi.spyOn(coreEvents, 'emitFeedback');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should emit a warning during install if skills directory is not empty but no skills are loaded', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'skills-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const skillsDir = path.join(sourceExtDir, 'skills');
|
||||
fs.mkdirSync(skillsDir);
|
||||
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
});
|
||||
|
||||
expect(extension.name).toBe('skills-ext');
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('Failed to load skills from'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit a warning during load if skills directory is not empty but no skills are loaded', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'load-skills-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const skillsDir = path.join(extDir, 'skills');
|
||||
fs.mkdirSync(skillsDir);
|
||||
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('Failed to load skills from'),
|
||||
);
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The directory is not empty but no valid skills were discovered',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed if skills are correctly loaded', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'good-skills-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const skillsDir = path.join(sourceExtDir, 'skills');
|
||||
const skillSubdir = path.join(skillsDir, 'test-skill');
|
||||
fs.mkdirSync(skillSubdir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillSubdir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test desc\n---\nbody',
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
});
|
||||
|
||||
expect(extension.skills).toHaveLength(1);
|
||||
expect(extension.skills![0].name).toBe('test-skill');
|
||||
// It might be called for other reasons during startup, but shouldn't be called for our skills loading success
|
||||
// Actually, it shouldn't be called with our warning message
|
||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('Failed to load skills from'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
logExtensionInstallEvent,
|
||||
logExtensionUninstall,
|
||||
logExtensionUpdateEvent,
|
||||
loadSkillsFromDir,
|
||||
type ExtensionEvents,
|
||||
type MCPServerConfig,
|
||||
type ExtensionInstallMetadata,
|
||||
@@ -262,10 +263,17 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
const newHasHooks = fs.existsSync(
|
||||
path.join(localSourcePath, 'hooks', 'hooks.json'),
|
||||
);
|
||||
let previousHasHooks = false;
|
||||
if (isUpdate && previous && previous.hooks) {
|
||||
previousHasHooks = Object.keys(previous.hooks).length > 0;
|
||||
}
|
||||
const previousHasHooks = !!(
|
||||
isUpdate &&
|
||||
previous &&
|
||||
previous.hooks &&
|
||||
Object.keys(previous.hooks).length > 0
|
||||
);
|
||||
|
||||
const newSkills = await loadSkillsFromDir(
|
||||
path.join(localSourcePath, 'skills'),
|
||||
);
|
||||
const previousSkills = previous?.skills ?? [];
|
||||
|
||||
await maybeRequestConsentOrFail(
|
||||
newExtensionConfig,
|
||||
@@ -273,6 +281,8 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
newHasHooks,
|
||||
previousExtensionConfig,
|
||||
previousHasHooks,
|
||||
newSkills,
|
||||
previousSkills,
|
||||
);
|
||||
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
|
||||
const destinationPath = new ExtensionStorage(
|
||||
@@ -551,6 +561,10 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
});
|
||||
}
|
||||
|
||||
const skills = await loadSkillsFromDir(
|
||||
path.join(effectiveExtensionPath, 'skills'),
|
||||
);
|
||||
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
@@ -567,6 +581,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
id: getExtensionId(config, installMetadata),
|
||||
settings: config.settings,
|
||||
resolvedSettings,
|
||||
skills,
|
||||
};
|
||||
this.loadedExtensions = [...this.loadedExtensions, extension];
|
||||
|
||||
@@ -721,6 +736,12 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
output += `\n ${tool}`;
|
||||
});
|
||||
}
|
||||
if (extension.skills && extension.skills.length > 0) {
|
||||
output += `\n Agent skills:`;
|
||||
extension.skills.forEach((skill) => {
|
||||
output += `\n ${skill.name}: ${skill.description}`;
|
||||
});
|
||||
}
|
||||
const resolvedSettings = extension.resolvedSettings;
|
||||
if (resolvedSettings && resolvedSettings.length > 0) {
|
||||
output += `\n Settings:`;
|
||||
|
||||
@@ -5,15 +5,20 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
requestConsentInteractive,
|
||||
maybeRequestConsentOrFail,
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
SKILLS_WARNING_MESSAGE,
|
||||
} from './consent.js';
|
||||
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
|
||||
|
||||
const mockReadline = vi.hoisted(() => ({
|
||||
createInterface: vi.fn().mockReturnValue({
|
||||
@@ -40,11 +45,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe('consent', () => {
|
||||
beforeEach(() => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'consent-test-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('requestConsentNonInteractive', () => {
|
||||
@@ -250,6 +262,102 @@ describe('consent', () => {
|
||||
);
|
||||
expect(requestConsent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should request consent if skills change', async () => {
|
||||
const skill1Dir = path.join(tempDir, 'skill1');
|
||||
const skill2Dir = path.join(tempDir, 'skill2');
|
||||
await fs.mkdir(skill1Dir, { recursive: true });
|
||||
await fs.mkdir(skill2Dir, { recursive: true });
|
||||
await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1');
|
||||
await fs.writeFile(path.join(skill1Dir, 'extra.txt'), 'extra');
|
||||
await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'body2');
|
||||
|
||||
const skill1: SkillDefinition = {
|
||||
name: 'skill1',
|
||||
description: 'desc1',
|
||||
location: path.join(skill1Dir, 'SKILL.md'),
|
||||
body: 'body1',
|
||||
};
|
||||
const skill2: SkillDefinition = {
|
||||
name: 'skill2',
|
||||
description: 'desc2',
|
||||
location: path.join(skill2Dir, 'SKILL.md'),
|
||||
body: 'body2',
|
||||
};
|
||||
|
||||
const config: ExtensionConfig = {
|
||||
...baseConfig,
|
||||
mcpServers: {
|
||||
server1: { command: 'npm', args: ['start'] },
|
||||
server2: { httpUrl: 'https://remote.com' },
|
||||
},
|
||||
contextFileName: 'my-context.md',
|
||||
excludeTools: ['tool1', 'tool2'],
|
||||
};
|
||||
const requestConsent = vi.fn().mockResolvedValue(true);
|
||||
await maybeRequestConsentOrFail(
|
||||
config,
|
||||
requestConsent,
|
||||
false,
|
||||
undefined,
|
||||
false,
|
||||
[skill1, skill2],
|
||||
);
|
||||
|
||||
const expectedConsentString = [
|
||||
'Installing extension "test-ext".',
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
'This extension will run the following MCP servers:',
|
||||
' * server1 (local): npm start',
|
||||
' * server2 (remote): https://remote.com',
|
||||
'This extension will append info to your gemini.md context using my-context.md',
|
||||
'This extension will exclude the following core tools: tool1,tool2',
|
||||
'',
|
||||
chalk.bold('Agent Skills:'),
|
||||
SKILLS_WARNING_MESSAGE,
|
||||
'This extension will install the following agent skills:',
|
||||
` * ${chalk.bold('skill1')}: desc1`,
|
||||
` (Location: ${skill1.location}) (2 items in directory)`,
|
||||
` * ${chalk.bold('skill2')}: desc2`,
|
||||
` (Location: ${skill2.location}) (1 items in directory)`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
|
||||
});
|
||||
|
||||
it('should show a warning if the skill directory cannot be read', async () => {
|
||||
const lockedDir = path.join(tempDir, 'locked');
|
||||
await fs.mkdir(lockedDir, { recursive: true, mode: 0o000 });
|
||||
|
||||
const skill: SkillDefinition = {
|
||||
name: 'locked-skill',
|
||||
description: 'A skill in a locked dir',
|
||||
location: path.join(lockedDir, 'SKILL.md'),
|
||||
body: 'body',
|
||||
};
|
||||
|
||||
const requestConsent = vi.fn().mockResolvedValue(true);
|
||||
try {
|
||||
await maybeRequestConsentOrFail(
|
||||
baseConfig,
|
||||
requestConsent,
|
||||
false,
|
||||
undefined,
|
||||
false,
|
||||
[skill],
|
||||
);
|
||||
|
||||
expect(requestConsent).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
` (Location: ${skill.location}) ${chalk.red('⚠️ (Could not count items in directory)')}`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
// Restore permissions so cleanup works
|
||||
await fs.chmod(lockedDir, 0o700);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||
import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
|
||||
export const INSTALL_WARNING_MESSAGE =
|
||||
'**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**';
|
||||
export const INSTALL_WARNING_MESSAGE = chalk.yellow(
|
||||
'The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.',
|
||||
);
|
||||
|
||||
export const SKILLS_WARNING_MESSAGE = chalk.yellow(
|
||||
"Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards.",
|
||||
);
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, by reading a Y/n
|
||||
@@ -38,7 +46,7 @@ export async function requestConsentNonInteractive(
|
||||
* This should not be called from non-interactive mode as it will not work.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
|
||||
* @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentInteractive(
|
||||
@@ -82,7 +90,7 @@ async function promptForConsentNonInteractive(
|
||||
* This should not be called from non-interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A markdown prompt to ask the user
|
||||
* @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
|
||||
* @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
|
||||
* @returns Whether or not the user answers yes.
|
||||
*/
|
||||
async function promptForConsentInteractive(
|
||||
@@ -103,10 +111,11 @@ async function promptForConsentInteractive(
|
||||
* Builds a consent string for installing an extension based on it's
|
||||
* extensionConfig.
|
||||
*/
|
||||
function extensionConsentString(
|
||||
async function extensionConsentString(
|
||||
extensionConfig: ExtensionConfig,
|
||||
hasHooks: boolean,
|
||||
): string {
|
||||
skills: SkillDefinition[] = [],
|
||||
): Promise<string> {
|
||||
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
|
||||
const output: string[] = [];
|
||||
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
|
||||
@@ -138,6 +147,24 @@ function extensionConsentString(
|
||||
'⚠️ This extension contains Hooks which can automatically execute commands.',
|
||||
);
|
||||
}
|
||||
if (skills.length > 0) {
|
||||
output.push(`\n${chalk.bold('Agent Skills:')}`);
|
||||
output.push(SKILLS_WARNING_MESSAGE);
|
||||
output.push('This extension will install the following agent skills:');
|
||||
for (const skill of skills) {
|
||||
output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`);
|
||||
const skillDir = path.dirname(skill.location);
|
||||
let fileCountStr = '';
|
||||
try {
|
||||
const skillDirItems = await fs.readdir(skillDir);
|
||||
fileCountStr = ` (${skillDirItems.length} items in directory)`;
|
||||
} catch {
|
||||
fileCountStr = ` ${chalk.red('⚠️ (Could not count items in directory)')}`;
|
||||
}
|
||||
output.push(` (Location: ${skill.location})${fileCountStr}`);
|
||||
}
|
||||
output.push('');
|
||||
}
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
@@ -156,12 +183,19 @@ export async function maybeRequestConsentOrFail(
|
||||
hasHooks: boolean,
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
previousHasHooks?: boolean,
|
||||
skills: SkillDefinition[] = [],
|
||||
previousSkills: SkillDefinition[] = [],
|
||||
) {
|
||||
const extensionConsent = extensionConsentString(extensionConfig, hasHooks);
|
||||
const extensionConsent = await extensionConsentString(
|
||||
extensionConfig,
|
||||
hasHooks,
|
||||
skills,
|
||||
);
|
||||
if (previousExtensionConfig) {
|
||||
const previousExtensionConsent = extensionConsentString(
|
||||
const previousExtensionConsent = await extensionConsentString(
|
||||
previousExtensionConfig,
|
||||
previousHasHooks ?? false,
|
||||
previousSkills,
|
||||
);
|
||||
if (previousExtensionConsent === extensionConsent) {
|
||||
return;
|
||||
|
||||
@@ -1136,15 +1136,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
useSmartEdit: {
|
||||
type: 'boolean',
|
||||
label: 'Use Smart Edit',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Enable the smart-edit tool instead of the replace tool.',
|
||||
showInDialog: false,
|
||||
},
|
||||
useWriteTodos: {
|
||||
type: 'boolean',
|
||||
label: 'Use WriteTodos',
|
||||
|
||||
@@ -546,7 +546,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
listExtensions: undefined,
|
||||
includeDirectories: undefined,
|
||||
screenReader: undefined,
|
||||
useSmartEdit: undefined,
|
||||
useWriteTodos: undefined,
|
||||
resume: undefined,
|
||||
listSessions: undefined,
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { directoryCommand } from './directoryCommand.js';
|
||||
import { expandHomeDir } from '../utils/directoryUtils.js';
|
||||
import {
|
||||
expandHomeDir,
|
||||
getDirectorySuggestions,
|
||||
} from '../utils/directoryUtils.js';
|
||||
import type { Config, WorkspaceContext } from '@google/gemini-cli-core';
|
||||
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
|
||||
import type { CommandContext, OpenCustomDialogActionReturn } from './types.js';
|
||||
@@ -17,6 +20,15 @@ import * as path from 'node:path';
|
||||
import * as trustedFolders from '../../config/trustedFolders.js';
|
||||
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
|
||||
|
||||
vi.mock('../utils/directoryUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/directoryUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getDirectorySuggestions: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('directoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockConfig: Config;
|
||||
@@ -217,6 +229,47 @@ describe('directoryCommand', () => {
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
describe('completion', () => {
|
||||
const completion = addCommand!.completion!;
|
||||
|
||||
it('should return empty suggestions for an empty path', async () => {
|
||||
const results = await completion(mockContext, '');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty suggestions for whitespace only path', async () => {
|
||||
const results = await completion(mockContext, ' ');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return suggestions for a single path', async () => {
|
||||
vi.mocked(getDirectorySuggestions).mockResolvedValue(['docs/', 'src/']);
|
||||
|
||||
const results = await completion(mockContext, 'd');
|
||||
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledWith('d');
|
||||
expect(results).toEqual(['docs/', 'src/']);
|
||||
});
|
||||
|
||||
it('should return suggestions for multiple paths', async () => {
|
||||
vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']);
|
||||
|
||||
const results = await completion(mockContext, 'docs/,s');
|
||||
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledWith('s');
|
||||
expect(results).toEqual(['docs/,src/']);
|
||||
});
|
||||
|
||||
it('should handle leading whitespace in suggestions', async () => {
|
||||
vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']);
|
||||
|
||||
const results = await completion(mockContext, 'docs/, s');
|
||||
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledWith('s');
|
||||
expect(results).toEqual(['docs/, src/']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('add with folder trust enabled', () => {
|
||||
|
||||
@@ -14,7 +14,10 @@ import type { SlashCommand, CommandContext } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType, type HistoryItem } from '../types.js';
|
||||
import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
|
||||
import { expandHomeDir } from '../utils/directoryUtils.js';
|
||||
import {
|
||||
expandHomeDir,
|
||||
getDirectorySuggestions,
|
||||
} from '../utils/directoryUtils.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
async function finishAddingDirectories(
|
||||
@@ -80,6 +83,27 @@ export const directoryCommand: SlashCommand = {
|
||||
'Add directories to the workspace. Use comma to separate multiple paths',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
showCompletionLoading: false,
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
// Support multiple paths separated by commas
|
||||
const parts = partialArg.split(',');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const leadingWhitespace = lastPart.match(/^\s*/)?.[0] ?? '';
|
||||
const trimmedLastPart = lastPart.trimStart();
|
||||
|
||||
if (trimmedLastPart === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const suggestions = await getDirectorySuggestions(trimmedLastPart);
|
||||
|
||||
if (parts.length > 1) {
|
||||
const prefix = parts.slice(0, -1).join(',') + ',';
|
||||
return suggestions.map((s) => prefix + leadingWhitespace + s);
|
||||
}
|
||||
|
||||
return suggestions.map((s) => leadingWhitespace + s);
|
||||
},
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const {
|
||||
ui: { addItem },
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
getMCPServerStatus,
|
||||
getMCPDiscoveryState,
|
||||
DiscoveredMCPTool,
|
||||
type MessageBus,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import { Type } from '@google/genai';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -37,6 +37,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockMessageBus = {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as MessageBus;
|
||||
|
||||
// Helper function to create a mock DiscoveredMCPTool
|
||||
const createMockMCPTool = (
|
||||
name: string,
|
||||
@@ -50,8 +56,14 @@ const createMockMCPTool = (
|
||||
} as unknown as CallableTool,
|
||||
serverName,
|
||||
name,
|
||||
description || `Description for ${name}`,
|
||||
{ type: Type.OBJECT, properties: {} },
|
||||
description || 'Mock tool description',
|
||||
{ type: 'object', properties: {} },
|
||||
mockMessageBus,
|
||||
undefined, // trust
|
||||
undefined, // nameOverride
|
||||
undefined, // cliConfig
|
||||
undefined, // extensionName
|
||||
undefined, // extensionId
|
||||
);
|
||||
|
||||
describe('mcpCommand', () => {
|
||||
|
||||
@@ -58,8 +58,20 @@ describe('skillsCommand', () => {
|
||||
expect.objectContaining({
|
||||
type: MessageType.SKILLS_LIST,
|
||||
skills: [
|
||||
{ name: 'skill1', description: 'desc1' },
|
||||
{ name: 'skill2', description: 'desc2' },
|
||||
{
|
||||
name: 'skill1',
|
||||
description: 'desc1',
|
||||
disabled: undefined,
|
||||
location: '/loc1',
|
||||
body: 'body1',
|
||||
},
|
||||
{
|
||||
name: 'skill2',
|
||||
description: 'desc2',
|
||||
disabled: undefined,
|
||||
location: '/loc2',
|
||||
body: 'body2',
|
||||
},
|
||||
],
|
||||
showDescriptions: true,
|
||||
}),
|
||||
@@ -75,8 +87,20 @@ describe('skillsCommand', () => {
|
||||
expect.objectContaining({
|
||||
type: MessageType.SKILLS_LIST,
|
||||
skills: [
|
||||
{ name: 'skill1', description: 'desc1' },
|
||||
{ name: 'skill2', description: 'desc2' },
|
||||
{
|
||||
name: 'skill1',
|
||||
description: 'desc1',
|
||||
disabled: undefined,
|
||||
location: '/loc1',
|
||||
body: 'body1',
|
||||
},
|
||||
{
|
||||
name: 'skill2',
|
||||
description: 'desc2',
|
||||
disabled: undefined,
|
||||
location: '/loc2',
|
||||
body: 'body2',
|
||||
},
|
||||
],
|
||||
showDescriptions: true,
|
||||
}),
|
||||
|
||||
@@ -45,6 +45,8 @@ async function listAction(
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
disabled: skill.disabled,
|
||||
location: skill.location,
|
||||
body: skill.body,
|
||||
})),
|
||||
showDescriptions: useShowDescriptions,
|
||||
};
|
||||
|
||||
@@ -201,5 +201,11 @@ export interface SlashCommand {
|
||||
partialArg: string,
|
||||
) => Promise<string[]> | string[];
|
||||
|
||||
/**
|
||||
* Whether to show the loading indicator while fetching completions.
|
||||
* Defaults to true. Set to false for fast completions to avoid flicker.
|
||||
*/
|
||||
showCompletionLoading?: boolean;
|
||||
|
||||
subCommands?: SlashCommand[];
|
||||
}
|
||||
|
||||
@@ -144,10 +144,16 @@ const createMockConfig = (overrides = {}) => ({
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getAccessibility: vi.fn(() => ({})),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
getMcpClientManager: vi.fn().mockImplementation(() => ({
|
||||
getBlockedMcpServers: vi.fn(),
|
||||
getMcpServers: vi.fn(),
|
||||
})),
|
||||
getToolRegistry: () => ({
|
||||
getTool: vi.fn(),
|
||||
}),
|
||||
getSkillManager: () => ({
|
||||
getSkills: () => [],
|
||||
}),
|
||||
getMcpClientManager: () => ({
|
||||
getMcpServers: () => ({}),
|
||||
getBlockedMcpServers: () => [],
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ export const Composer = () => {
|
||||
blockedMcpServers={
|
||||
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
||||
}
|
||||
skillCount={config.getSkillManager().getSkills().length}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -34,14 +34,16 @@ describe('<ContextSummaryDisplay />', () => {
|
||||
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
|
||||
},
|
||||
},
|
||||
skillCount: 1,
|
||||
};
|
||||
|
||||
it('should render on a single line on a wide screen', () => {
|
||||
const { lastFrame, unmount } = renderWithWidth(120, baseProps);
|
||||
const output = lastFrame()!;
|
||||
expect(output).toContain(
|
||||
'Using: 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server',
|
||||
'1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill',
|
||||
);
|
||||
expect(output).not.toContain('Using:');
|
||||
// Check for absence of newlines
|
||||
expect(output.includes('\n')).toBe(false);
|
||||
unmount();
|
||||
@@ -51,10 +53,10 @@ describe('<ContextSummaryDisplay />', () => {
|
||||
const { lastFrame, unmount } = renderWithWidth(60, baseProps);
|
||||
const output = lastFrame()!;
|
||||
const expectedLines = [
|
||||
' Using:',
|
||||
' - 1 open file (ctrl+g to view)',
|
||||
' - 1 GEMINI.md file',
|
||||
' - 1 MCP server',
|
||||
' - 1 open file (ctrl+g to view)',
|
||||
' - 1 GEMINI.md file',
|
||||
' - 1 MCP server',
|
||||
' - 1 skill',
|
||||
];
|
||||
const actualLines = output.split('\n');
|
||||
expect(actualLines).toEqual(expectedLines);
|
||||
@@ -86,9 +88,10 @@ describe('<ContextSummaryDisplay />', () => {
|
||||
geminiMdFileCount: 0,
|
||||
contextFileNames: [],
|
||||
mcpServers: {},
|
||||
skillCount: 0,
|
||||
};
|
||||
const { lastFrame, unmount } = renderWithWidth(60, props);
|
||||
const expectedLines = [' Using:', ' - 1 open file (ctrl+g to view)'];
|
||||
const expectedLines = [' - 1 open file (ctrl+g to view)'];
|
||||
const actualLines = lastFrame()!.split('\n');
|
||||
expect(actualLines).toEqual(expectedLines);
|
||||
unmount();
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ContextSummaryDisplayProps {
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
|
||||
ideContext?: IdeContext;
|
||||
skillCount: number;
|
||||
}
|
||||
|
||||
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
@@ -25,6 +26,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
mcpServers,
|
||||
blockedMcpServers,
|
||||
ideContext,
|
||||
skillCount,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
@@ -36,7 +38,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
geminiMdFileCount === 0 &&
|
||||
mcpServerCount === 0 &&
|
||||
blockedMcpServerCount === 0 &&
|
||||
openFileCount === 0
|
||||
openFileCount === 0 &&
|
||||
skillCount === 0
|
||||
) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
}
|
||||
@@ -83,15 +86,23 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
return parts.join(', ');
|
||||
})();
|
||||
|
||||
const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean);
|
||||
const skillText = (() => {
|
||||
if (skillCount === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${skillCount} skill${skillCount > 1 ? 's' : ''}`;
|
||||
})();
|
||||
|
||||
const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter(
|
||||
Boolean,
|
||||
);
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>Using:</Text>
|
||||
{summaryParts.map((part, index) => (
|
||||
<Text key={index} color={theme.text.secondary}>
|
||||
{' '}- {part}
|
||||
- {part}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
@@ -100,9 +111,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Using: {summaryParts.join(' | ')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{summaryParts.join(' | ')}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,13 +7,31 @@
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SkillsList } from './SkillsList.js';
|
||||
import { type SkillDefinition } from '../../types.js';
|
||||
import { type SkillDefinition } from '@google/gemini-cli-core';
|
||||
|
||||
describe('SkillsList Component', () => {
|
||||
const mockSkills: SkillDefinition[] = [
|
||||
{ name: 'skill1', description: 'description 1', disabled: false },
|
||||
{ name: 'skill2', description: 'description 2', disabled: true },
|
||||
{ name: 'skill3', description: 'description 3', disabled: false },
|
||||
{
|
||||
name: 'skill1',
|
||||
description: 'description 1',
|
||||
disabled: false,
|
||||
location: 'loc1',
|
||||
body: 'body1',
|
||||
},
|
||||
{
|
||||
name: 'skill2',
|
||||
description: 'description 2',
|
||||
disabled: true,
|
||||
location: 'loc2',
|
||||
body: 'body2',
|
||||
},
|
||||
{
|
||||
name: 'skill3',
|
||||
description: 'description 3',
|
||||
disabled: false,
|
||||
location: 'loc3',
|
||||
body: 'body3',
|
||||
},
|
||||
];
|
||||
|
||||
it('should render enabled and disabled skills separately', () => {
|
||||
|
||||
@@ -54,6 +54,12 @@ describe('handleAtCommand', () => {
|
||||
|
||||
const getToolRegistry = vi.fn();
|
||||
|
||||
const mockMessageBus = {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as core.MessageBus;
|
||||
|
||||
mockConfig = {
|
||||
getToolRegistry,
|
||||
getTargetDir: () => testRootDir,
|
||||
@@ -94,11 +100,12 @@ describe('handleAtCommand', () => {
|
||||
getMcpClientManager: () => ({
|
||||
getClient: () => undefined,
|
||||
}),
|
||||
getMessageBus: () => mockMessageBus,
|
||||
} as unknown as Config;
|
||||
|
||||
const registry = new ToolRegistry(mockConfig);
|
||||
registry.registerTool(new ReadManyFilesTool(mockConfig));
|
||||
registry.registerTool(new GlobTool(mockConfig));
|
||||
const registry = new ToolRegistry(mockConfig, mockMessageBus);
|
||||
registry.registerTool(new ReadManyFilesTool(mockConfig, mockMessageBus));
|
||||
registry.registerTool(new GlobTool(mockConfig, mockMessageBus));
|
||||
getToolRegistry.mockReturnValue(registry);
|
||||
});
|
||||
|
||||
|
||||
@@ -164,7 +164,10 @@ export async function handleAtCommand({
|
||||
};
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const readManyFilesTool = new ReadManyFilesTool(config);
|
||||
const readManyFilesTool = new ReadManyFilesTool(
|
||||
config,
|
||||
config.getMessageBus(),
|
||||
);
|
||||
const globTool = toolRegistry.getTool('glob');
|
||||
|
||||
if (!readManyFilesTool) {
|
||||
|
||||
@@ -220,7 +220,6 @@ describe('useGeminiStream', () => {
|
||||
getContentGeneratorConfig: vi
|
||||
.fn()
|
||||
.mockReturnValue(contentGeneratorConfig),
|
||||
getUseSmartEdit: () => false,
|
||||
isInteractive: () => false,
|
||||
getExperiments: () => {},
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -133,8 +133,7 @@ export function useQuotaAndFallback({
|
||||
// Set the model to the fallback model for the current session.
|
||||
// This ensures the Footer updates and future turns use this model.
|
||||
// The change is not persisted, so the original model is restored on restart.
|
||||
config.setModel(proQuotaRequest.fallbackModel, true);
|
||||
|
||||
config.activateFallbackMode(proQuotaRequest.fallbackModel);
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
|
||||
@@ -212,7 +212,10 @@ function useCommandSuggestions(
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const showLoading = leafCommand.showCompletionLoading !== false;
|
||||
if (showLoading) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const rawParts = [...commandPathParts];
|
||||
if (partial) rawParts.push(partial);
|
||||
|
||||
@@ -31,10 +31,10 @@ import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
ToolConfirmationOutcome,
|
||||
ApprovalMode,
|
||||
MockTool,
|
||||
HookSystem,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js';
|
||||
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
|
||||
@@ -77,7 +77,6 @@ const mockConfig = {
|
||||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getUseSmartEdit: () => false,
|
||||
getGeminiClient: () => null, // No client needed for these tests
|
||||
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
|
||||
getMessageBus: () => null,
|
||||
|
||||
@@ -13,11 +13,12 @@ import type {
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
RetrieveUserQuotaResponse,
|
||||
SkillDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { PartListUnion } from '@google/genai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export type { ThoughtSummary };
|
||||
export type { ThoughtSummary, SkillDefinition };
|
||||
|
||||
export enum AuthState {
|
||||
// Attempting to authenticate or re-authenticate
|
||||
@@ -211,12 +212,6 @@ export type HistoryItemToolsList = HistoryItemBase & {
|
||||
showDescriptions: boolean;
|
||||
};
|
||||
|
||||
export interface SkillDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type HistoryItemSkillsList = HistoryItemBase & {
|
||||
type: 'skills_list';
|
||||
skills: SkillDefinition[];
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect } from 'vitest';
|
||||
import { expandHomeDir } from './directoryUtils.js';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { expandHomeDir, getDirectorySuggestions } from './directoryUtils.js';
|
||||
import type * as osActual from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
@@ -33,7 +35,47 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
opendir: vi.fn(),
|
||||
}));
|
||||
|
||||
interface MockDirent {
|
||||
name: string;
|
||||
isDirectory: () => boolean;
|
||||
}
|
||||
|
||||
function createMockDir(entries: MockDirent[]) {
|
||||
let index = 0;
|
||||
const iterator = {
|
||||
async next() {
|
||||
if (index < entries.length) {
|
||||
return { value: entries[index++], done: false };
|
||||
}
|
||||
return { value: undefined, done: true };
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
return iterator;
|
||||
},
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('directoryUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('expandHomeDir', () => {
|
||||
it('should expand ~ to the home directory', () => {
|
||||
expect(expandHomeDir('~')).toBe(mockHomeDir);
|
||||
@@ -60,4 +102,216 @@ describe('directoryUtils', () => {
|
||||
expect(expandHomeDir('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDirectorySuggestions', () => {
|
||||
it('should return suggestions for an empty path', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: 'docs', isDirectory: () => true },
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'file.txt', isDirectory: () => false },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
const suggestions = await getDirectorySuggestions('');
|
||||
expect(suggestions).toEqual([`docs${path.sep}`, `src${path.sep}`]);
|
||||
});
|
||||
|
||||
it('should return suggestions for a partial path', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: 'docs', isDirectory: () => true },
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
const suggestions = await getDirectorySuggestions('d');
|
||||
expect(suggestions).toEqual([`docs${path.sep}`]);
|
||||
});
|
||||
|
||||
it('should return suggestions for a path with trailing slash', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: 'sub', isDirectory: () => true },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
const suggestions = await getDirectorySuggestions('docs/');
|
||||
expect(suggestions).toEqual(['docs/sub/']);
|
||||
});
|
||||
|
||||
it('should return suggestions for a path with ~', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: 'Downloads', isDirectory: () => true },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
const suggestions = await getDirectorySuggestions('~/');
|
||||
expect(suggestions).toEqual(['~/Downloads/']);
|
||||
});
|
||||
|
||||
it('should return suggestions for a partial path with ~', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: 'Downloads', isDirectory: () => true },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
const suggestions = await getDirectorySuggestions('~/Down');
|
||||
expect(suggestions).toEqual(['~/Downloads/']);
|
||||
});
|
||||
|
||||
it('should return suggestions for ../', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: 'other-project', isDirectory: () => true },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
const suggestions = await getDirectorySuggestions('../');
|
||||
expect(suggestions).toEqual(['../other-project/']);
|
||||
});
|
||||
|
||||
it('should ignore hidden directories', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: '.git', isDirectory: () => true },
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
const suggestions = await getDirectorySuggestions('');
|
||||
expect(suggestions).toEqual([`src${path.sep}`]);
|
||||
});
|
||||
|
||||
it('should show hidden directories when filter starts with .', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: '.git', isDirectory: () => true },
|
||||
{ name: '.github', isDirectory: () => true },
|
||||
{ name: '.vscode', isDirectory: () => true },
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
const suggestions = await getDirectorySuggestions('.g');
|
||||
expect(suggestions).toEqual([`.git${path.sep}`, `.github${path.sep}`]);
|
||||
});
|
||||
|
||||
it('should return empty array if directory does not exist', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
const suggestions = await getDirectorySuggestions('nonexistent/');
|
||||
expect(suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should limit results to 50 suggestions', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
|
||||
// Create 200 directories
|
||||
const manyDirs = Array.from({ length: 200 }, (_, i) => ({
|
||||
name: `dir${String(i).padStart(3, '0')}`,
|
||||
isDirectory: () => true,
|
||||
}));
|
||||
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir(manyDirs) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
const suggestions = await getDirectorySuggestions('');
|
||||
expect(suggestions).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('should terminate early after 150 matches for performance', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
|
||||
// Create 200 directories
|
||||
const manyDirs = Array.from({ length: 200 }, (_, i) => ({
|
||||
name: `dir${String(i).padStart(3, '0')}`,
|
||||
isDirectory: () => true,
|
||||
}));
|
||||
|
||||
const mockDir = createMockDir(manyDirs);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
mockDir as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
await getDirectorySuggestions('');
|
||||
|
||||
// The close method should be called, indicating early termination
|
||||
expect(mockDir.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform !== 'win32')(
|
||||
'getDirectorySuggestions (Windows)',
|
||||
() => {
|
||||
it('should handle %userprofile% expansion', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: 'Documents', isDirectory: () => true },
|
||||
{ name: 'Downloads', isDirectory: () => true },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
expect(await getDirectorySuggestions('%userprofile%\\')).toEqual([
|
||||
`%userprofile%\\Documents${path.sep}`,
|
||||
`%userprofile%\\Downloads${path.sep}`,
|
||||
]);
|
||||
|
||||
vi.mocked(fsPromises.opendir).mockResolvedValue(
|
||||
createMockDir([
|
||||
{ name: 'Documents', isDirectory: () => true },
|
||||
{ name: 'Downloads', isDirectory: () => true },
|
||||
]) as unknown as fs.Dir,
|
||||
);
|
||||
|
||||
expect(await getDirectorySuggestions('%userprofile%\\Doc')).toEqual([
|
||||
`%userprofile%\\Documents${path.sep}`,
|
||||
]);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { opendir } from 'node:fs/promises';
|
||||
|
||||
const MAX_SUGGESTIONS = 50;
|
||||
const MATCH_BUFFER_MULTIPLIER = 3;
|
||||
|
||||
export function expandHomeDir(p: string): string {
|
||||
if (!p) {
|
||||
@@ -19,3 +24,118 @@ export function expandHomeDir(p: string): string {
|
||||
}
|
||||
return path.normalize(expandedPath);
|
||||
}
|
||||
|
||||
interface ParsedPath {
|
||||
searchDir: string;
|
||||
filter: string;
|
||||
isHomeExpansion: boolean;
|
||||
resultPrefix: string;
|
||||
}
|
||||
|
||||
function parsePartialPath(partialPath: string): ParsedPath {
|
||||
const isHomeExpansion = partialPath.startsWith('~');
|
||||
const expandedPath = expandHomeDir(partialPath || '.');
|
||||
|
||||
let searchDir: string;
|
||||
let filter: string;
|
||||
|
||||
if (
|
||||
partialPath === '' ||
|
||||
partialPath.endsWith('/') ||
|
||||
partialPath.endsWith(path.sep)
|
||||
) {
|
||||
searchDir = expandedPath;
|
||||
filter = '';
|
||||
} else {
|
||||
searchDir = path.dirname(expandedPath);
|
||||
filter = path.basename(expandedPath);
|
||||
|
||||
// Special case for ~ because path.dirname('~') can be '.'
|
||||
if (
|
||||
isHomeExpansion &&
|
||||
!partialPath.includes('/') &&
|
||||
!partialPath.includes(path.sep)
|
||||
) {
|
||||
searchDir = os.homedir();
|
||||
filter = partialPath.substring(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate result prefix
|
||||
let resultPrefix = '';
|
||||
if (
|
||||
partialPath === '' ||
|
||||
partialPath.endsWith('/') ||
|
||||
partialPath.endsWith(path.sep)
|
||||
) {
|
||||
resultPrefix = partialPath;
|
||||
} else {
|
||||
const lastSlashIndex = Math.max(
|
||||
partialPath.lastIndexOf('/'),
|
||||
partialPath.lastIndexOf(path.sep),
|
||||
);
|
||||
if (lastSlashIndex !== -1) {
|
||||
resultPrefix = partialPath.substring(0, lastSlashIndex + 1);
|
||||
} else if (isHomeExpansion) {
|
||||
resultPrefix = `~${path.sep}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { searchDir, filter, isHomeExpansion, resultPrefix };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets directory suggestions based on a partial path.
|
||||
* Uses async iteration with fs.opendir for efficient handling of large directories.
|
||||
*
|
||||
* @param partialPath The partial path typed by the user.
|
||||
* @returns A promise resolving to an array of directory path suggestions.
|
||||
*/
|
||||
export async function getDirectorySuggestions(
|
||||
partialPath: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const { searchDir, filter, resultPrefix } = parsePartialPath(partialPath);
|
||||
|
||||
if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches: string[] = [];
|
||||
const filterLower = filter.toLowerCase();
|
||||
const showHidden = filter.startsWith('.');
|
||||
const dir = await opendir(searchDir);
|
||||
|
||||
try {
|
||||
for await (const entry of dir) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name.startsWith('.') && !showHidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.toLowerCase().startsWith(filterLower)) {
|
||||
matches.push(entry.name);
|
||||
|
||||
// Early termination with buffer for sorting
|
||||
if (matches.length >= MAX_SUGGESTIONS * MATCH_BUFFER_MULTIPLIER) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await dir.close().catch(() => {});
|
||||
}
|
||||
|
||||
// Use the separator style from user's input for consistency
|
||||
const userSep = resultPrefix.includes('/') ? '/' : path.sep;
|
||||
|
||||
return matches
|
||||
.sort()
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
.map((name) => resultPrefix + name + userSep);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ReadManyFilesTool,
|
||||
type GeminiChat,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { SettingScope, type LoadedSettings } from '../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
@@ -97,6 +98,11 @@ describe('GeminiAgent', () => {
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
startChat: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
@@ -261,6 +267,7 @@ describe('Session', () => {
|
||||
let session: Session;
|
||||
let mockToolRegistry: { getTool: Mock };
|
||||
let mockTool: { kind: string; build: Mock };
|
||||
let mockMessageBus: Mocked<MessageBus>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChat = {
|
||||
@@ -279,6 +286,11 @@ describe('Session', () => {
|
||||
mockToolRegistry = {
|
||||
getTool: vi.fn().mockReturnValue(mockTool),
|
||||
};
|
||||
mockMessageBus = {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as Mocked<MessageBus>;
|
||||
mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue({}),
|
||||
@@ -290,6 +302,7 @@ describe('Session', () => {
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
} as unknown as Mocked<Config>;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
|
||||
@@ -609,7 +609,10 @@ export class Session {
|
||||
const ignoredPaths: string[] = [];
|
||||
|
||||
const toolRegistry = this.config.getToolRegistry();
|
||||
const readManyFilesTool = new ReadManyFilesTool(this.config);
|
||||
const readManyFilesTool = new ReadManyFilesTool(
|
||||
this.config,
|
||||
this.config.getMessageBus(),
|
||||
);
|
||||
const globTool = toolRegistry.getTool('glob');
|
||||
|
||||
if (!readManyFilesTool) {
|
||||
|
||||
Reference in New Issue
Block a user