mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 12:41:00 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 413098e03a | |||
| 66abea7a0e | |||
| dc82a395ac |
@@ -1452,6 +1452,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.shell.rcFile`** (string):
|
||||
- **Description:** The path to a bash file (e.g., .bashrc) to source before
|
||||
executing shell commands.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`tools.core`** (array):
|
||||
- **Description:** Restrict the set of built-in tools with an allowlist. Match
|
||||
semantics mirror tools.allowed; see the built-in tools documentation for
|
||||
|
||||
@@ -8,16 +8,7 @@ import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Skip on macOS: every interactive test in this file is chronically flaky
|
||||
// because the captured pty buffer contains the CLI's startup escape
|
||||
// sequences (`q4;?m...true color warning`) instead of the streamed output,
|
||||
// causing `expectText(...)` to time out. Reproducible across unrelated
|
||||
// runs on `main` (24740161950, 24739323404) and on consecutive merge-queue
|
||||
// gates for #25753 (24743605639, 24747624513) — different tests in the
|
||||
// same describe fail on different runs. Not specific to any model.
|
||||
const skipOnDarwin = process.platform === 'darwin';
|
||||
|
||||
describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
|
||||
describe('Interactive Mode', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -134,9 +134,7 @@ describe('file-system', () => {
|
||||
).toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
// Trim to tolerate models that idiomatically append a trailing newline.
|
||||
// This test is about path-with-spaces handling, not whitespace fidelity.
|
||||
expect(newFileContent.trim()).toBe('hello');
|
||||
expect(newFileContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('should perform a read-then-write sequence', async () => {
|
||||
|
||||
@@ -81,10 +81,7 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args:
|
||||
'Create a file called plan.md in the plans directory with the ' +
|
||||
'content "# Plan". Treat this as a Directive and write the file ' +
|
||||
'immediately without proposing strategy or asking for confirmation.',
|
||||
args: 'Create a file called plan.md in the plans directory.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
@@ -197,11 +194,7 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
args:
|
||||
'Create a file called plan-no-session.md in the plans directory ' +
|
||||
'with the content "# Plan". Treat this as a Directive and write ' +
|
||||
'the file immediately without proposing strategy or asking for ' +
|
||||
'confirmation.',
|
||||
args: 'Create a file called plan-no-session.md in the plans directory.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
@@ -925,6 +925,7 @@ export async function loadCliConfig(
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand,
|
||||
shellToolRcFile: settings.tools?.shell?.rcFile,
|
||||
mcpServers,
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
|
||||
@@ -1637,6 +1637,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable shell output efficiency optimizations for better performance.',
|
||||
showInDialog: false,
|
||||
},
|
||||
rcFile: {
|
||||
type: 'string',
|
||||
label: 'Shell Tool RC File',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'The path to a bash file (e.g., .bashrc) to source before executing shell commands.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { noteCommand } from '../ui/commands/noteCommand.js';
|
||||
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { planCommand } from '../ui/commands/planCommand.js';
|
||||
@@ -185,9 +184,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [mcpCommand]),
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
...(this.config?.getFolderTrust()
|
||||
? [permissionsCommand, noteCommand]
|
||||
: []),
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||
policiesCommand,
|
||||
privacyCommand,
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { noteCommand } from './noteCommand.js';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import * as path from 'node:path';
|
||||
import type { MessageActionReturn } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
describe('noteCommand', () => {
|
||||
const mockContext = createMockCommandContext();
|
||||
const workspaceRoot = process.cwd();
|
||||
const notesFile = path.join(workspaceRoot, 'notes.md');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('append action', () => {
|
||||
it('should append a note to notes.md when args are provided', async () => {
|
||||
const noteText = 'This is a test note';
|
||||
vi.mocked(fsPromises.appendFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = (await noteCommand.action!(
|
||||
mockContext,
|
||||
noteText,
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(fsPromises.appendFile).toHaveBeenCalledWith(
|
||||
notesFile,
|
||||
expect.stringContaining(noteText),
|
||||
);
|
||||
expect(fsPromises.appendFile).toHaveBeenCalledWith(
|
||||
notesFile,
|
||||
expect.stringContaining('## '),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Note saved to ${notesFile}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a usage message when no args are provided', async () => {
|
||||
const result = (await noteCommand.action!(
|
||||
mockContext,
|
||||
' ',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(fsPromises.appendFile).not.toHaveBeenCalled();
|
||||
expect(result.content).toContain('Please provide a note to save');
|
||||
});
|
||||
|
||||
it('should return an error message when appendFile fails', async () => {
|
||||
vi.mocked(fsPromises.appendFile).mockRejectedValue(new Error('FS Error'));
|
||||
|
||||
const result = (await noteCommand.action!(
|
||||
mockContext,
|
||||
'some note',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.content).toContain('Failed to save note: FS Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('view subcommand', () => {
|
||||
const viewSubcommand = noteCommand.subCommands?.find(
|
||||
(s) => s.name === 'view',
|
||||
);
|
||||
|
||||
it('should read and display notes from notes.md', async () => {
|
||||
const notesContent = '## 4/21/2026\n\nTest note content';
|
||||
vi.mocked(fsPromises.readFile).mockResolvedValue(notesContent);
|
||||
|
||||
const result = (await viewSubcommand!.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(fsPromises.readFile).toHaveBeenCalledWith(notesFile, 'utf8');
|
||||
expect(result.content).toContain('### Current Notes');
|
||||
expect(result.content).toContain(notesContent);
|
||||
});
|
||||
|
||||
it('should return "No notes found" if notes.md does not exist', async () => {
|
||||
const error = new Error('Not found');
|
||||
Object.assign(error, { code: 'ENOENT' });
|
||||
vi.mocked(fsPromises.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = (await viewSubcommand!.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.content).toContain('No notes found in this workspace.');
|
||||
});
|
||||
|
||||
it('should return an error message when readFile fails with other error', async () => {
|
||||
vi.mocked(fsPromises.readFile).mockRejectedValue(new Error('Read Error'));
|
||||
|
||||
const result = (await viewSubcommand!.action!(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
expect(result.content).toContain('Failed to read notes: Read Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const noteCommand: SlashCommand = {
|
||||
name: 'note',
|
||||
description: 'Manage workspace notes (append or view)',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'view',
|
||||
description: 'View the current workspace notes',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: async () => {
|
||||
const workspaceRoot = process.cwd();
|
||||
const notesFile = path.join(workspaceRoot, 'notes.md');
|
||||
|
||||
try {
|
||||
const content = await fsPromises.readFile(notesFile, 'utf8');
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `### Current Notes\n\n${content}`,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No notes found in this workspace.',
|
||||
};
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to read notes: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
action: async (context, args) => {
|
||||
if (!args.trim()) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Please provide a note to save or use `/note view` to see your notes. Example: `/note This is a useful thought.`',
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const notesFile = path.join(workspaceRoot, 'notes.md');
|
||||
const timestamp = new Date().toLocaleString();
|
||||
|
||||
const noteEntry = `\n## ${timestamp}\n\n${args.trim()}\n`;
|
||||
|
||||
try {
|
||||
await fsPromises.appendFile(notesFile, noteEntry);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Note saved to ${notesFile}`,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to save note: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -602,6 +602,7 @@ export interface ConfigParameters {
|
||||
toolDiscoveryCommand?: string;
|
||||
toolCallCommand?: string;
|
||||
mcpServerCommand?: string;
|
||||
shellToolRcFile?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||
userMemory?: string | HierarchicalMemory;
|
||||
@@ -779,6 +780,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly toolDiscoveryCommand: string | undefined;
|
||||
private readonly toolCallCommand: string | undefined;
|
||||
private readonly mcpServerCommand: string | undefined;
|
||||
private readonly shellToolRcFile: string | undefined;
|
||||
private readonly mcpEnabled: boolean;
|
||||
private readonly extensionsEnabled: boolean;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
@@ -1047,6 +1049,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.toolDiscoveryCommand = params.toolDiscoveryCommand;
|
||||
this.toolCallCommand = params.toolCallCommand;
|
||||
this.mcpServerCommand = params.mcpServerCommand;
|
||||
this.shellToolRcFile = params.shellToolRcFile;
|
||||
this.mcpServers = params.mcpServers;
|
||||
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
|
||||
this.mcpEnabled = params.mcpEnabled ?? true;
|
||||
@@ -2318,6 +2321,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.mcpServerCommand;
|
||||
}
|
||||
|
||||
getShellToolRcFile(): string | undefined {
|
||||
return this.shellToolRcFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user configured MCP servers (via gemini settings files).
|
||||
*
|
||||
|
||||
@@ -164,6 +164,8 @@ describe('ShellTool', () => {
|
||||
addPersistentApproval: vi.fn(),
|
||||
addSessionApproval: vi.fn(),
|
||||
},
|
||||
getShellToolRcFile: vi.fn().mockReturnValue(undefined),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
const bus = createMockMessageBus();
|
||||
@@ -486,6 +488,70 @@ EOF`;
|
||||
expect(mockShellExecutionService.mock.calls[0][0]).toMatch(/\nEOF\n\)\n/);
|
||||
});
|
||||
|
||||
it('should source rcfile when shellToolRcFile setting is present and folder is trusted', async () => {
|
||||
const rcFilePath = '~/.geminirc';
|
||||
(mockConfig.getShellToolRcFile as Mock).mockReturnValue(rcFilePath);
|
||||
(mockConfig.isTrustedFolder as Mock).mockReturnValue(true);
|
||||
|
||||
const invocation = shellTool.build({ command: 'my-command' });
|
||||
const promise = invocation.execute({ abortSignal: mockAbortSignal });
|
||||
resolveShellExecution();
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`source '${rcFilePath.replace('~', '/home/user')}'; export PAGER=cat`,
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT source rcfile when shellToolRcFile setting is present but folder is untrusted', async () => {
|
||||
const rcFilePath = '~/.geminirc';
|
||||
(mockConfig.getShellToolRcFile as Mock).mockReturnValue(rcFilePath);
|
||||
(mockConfig.isTrustedFolder as Mock).mockReturnValue(false);
|
||||
|
||||
const invocation = shellTool.build({ command: 'my-command' });
|
||||
const promise = invocation.execute({ abortSignal: mockAbortSignal });
|
||||
resolveShellExecution();
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(`source '${rcFilePath}'`),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly escape quotes in rcFilePath', async () => {
|
||||
const rcFilePath = "/path/with/'quotes'/rc";
|
||||
(mockConfig.getShellToolRcFile as Mock).mockReturnValue(rcFilePath);
|
||||
(mockConfig.isTrustedFolder as Mock).mockReturnValue(true);
|
||||
|
||||
const invocation = shellTool.build({ command: 'my-command' });
|
||||
const promise = invocation.execute({ abortSignal: mockAbortSignal });
|
||||
resolveShellExecution();
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`source '/path/with/'\\''quotes'\\''/rc'; export PAGER=cat`,
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should format error messages correctly', async () => {
|
||||
const error = new Error('wrapped command failed');
|
||||
const invocation = shellTool.build({ command: 'user-command' });
|
||||
|
||||
@@ -50,7 +50,12 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { getShellDefinition } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { toPathKey, isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import {
|
||||
homedir,
|
||||
toPathKey,
|
||||
isSubpath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import {
|
||||
getProactiveToolSuggestions,
|
||||
isNetworkReliantCommand,
|
||||
@@ -462,13 +467,30 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
const combinedController = new AbortController();
|
||||
|
||||
const onAbort = () => combinedController.abort();
|
||||
let strippedCommandWithRc = strippedCommand;
|
||||
if (!isWindows) {
|
||||
strippedCommandWithRc = `export PAGER=cat GIT_PAGER=cat; ${strippedCommand}`;
|
||||
const rcFilePath = this.context.config.getShellToolRcFile();
|
||||
if (rcFilePath && this.context.config.isTrustedFolder()) {
|
||||
let resolvedRcFilePath = rcFilePath;
|
||||
if (rcFilePath === '~' || rcFilePath.startsWith('~/')) {
|
||||
resolvedRcFilePath = homedir() + rcFilePath.substring(1);
|
||||
}
|
||||
const escapedRcFilePath = resolvedRcFilePath.replace(
|
||||
/'/g,
|
||||
() => "'\\''",
|
||||
);
|
||||
strippedCommandWithRc = `source '${escapedRcFilePath}'; ${strippedCommandWithRc}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-shell-'));
|
||||
tempFilePath = path.join(tempDir, 'pgrep.tmp');
|
||||
|
||||
// pgrep is not available on Windows, so we can't get background PIDs
|
||||
const commandToExecute = this.wrapCommandForPgrep(
|
||||
strippedCommand,
|
||||
strippedCommandWithRc,
|
||||
tempFilePath,
|
||||
isWindows,
|
||||
);
|
||||
|
||||
@@ -11,10 +11,7 @@ import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { env } from 'node:process';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import {
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
GEMINI_DIR,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { PREVIEW_GEMINI_MODEL, GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
export { GEMINI_DIR };
|
||||
import * as pty from '@lydell/node-pty';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
@@ -478,7 +475,7 @@ export class TestRig {
|
||||
...(env['GEMINI_TEST_TYPE'] === 'integration'
|
||||
? {
|
||||
model: {
|
||||
name: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
name: PREVIEW_GEMINI_MODEL,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -2509,6 +2509,12 @@
|
||||
"markdownDescription": "Enable shell output efficiency optimizations for better performance.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"rcFile": {
|
||||
"title": "Shell Tool RC File",
|
||||
"description": "The path to a bash file (e.g., .bashrc) to source before executing shell commands.",
|
||||
"markdownDescription": "The path to a bash file (e.g., .bashrc) to source before executing shell commands.\n\n- Category: `Tools`\n- Requires restart: `no`",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
Reference in New Issue
Block a user