summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/cli/commands.md11
-rw-r--r--packages/cli/src/services/BuiltinCommandLoader.ts2
-rw-r--r--packages/cli/src/ui/commands/directoryCommand.test.tsx172
-rw-r--r--packages/cli/src/ui/commands/directoryCommand.tsx150
-rw-r--r--packages/core/src/config/config.ts13
-rw-r--r--packages/core/src/core/client.ts30
6 files changed, 377 insertions, 1 deletions
diff --git a/docs/cli/commands.md b/docs/cli/commands.md
index d5072ab3..58717635 100644
--- a/docs/cli/commands.md
+++ b/docs/cli/commands.md
@@ -38,6 +38,17 @@ Slash commands provide meta-level control over the CLI itself.
- **`/copy`**
- **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse.
+- **`/directory`** (or **`/dir`**)
+ - **Description:** Manage workspace directories for multi-directory support.
+ - **Sub-commands:**
+ - **`add`**:
+ - **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well.
+ - **Usage:** `/directory add <path1>,<path2>`
+ - **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
+ - **`show`**:
+ - **Description:** Display all directories added by `/direcotry add` and `--include-directories`.
+ - **Usage:** `/directory show`
+
- **`/editor`**
- **Description:** Open a dialog for selecting supported editors.
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index d3ad6cb2..3b54047c 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -16,6 +16,7 @@ import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
+import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
@@ -56,6 +57,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
copyCommand,
corgiCommand,
docsCommand,
+ directoryCommand,
editorCommand,
extensionsCommand,
helpCommand,
diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx
new file mode 100644
index 00000000..081083d3
--- /dev/null
+++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx
@@ -0,0 +1,172 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { directoryCommand, expandHomeDir } from './directoryCommand.js';
+import { Config, WorkspaceContext } from '@google/gemini-cli-core';
+import { CommandContext } from './types.js';
+import { MessageType } from '../types.js';
+import * as os from 'os';
+import * as path from 'path';
+
+describe('directoryCommand', () => {
+ let mockContext: CommandContext;
+ let mockConfig: Config;
+ let mockWorkspaceContext: WorkspaceContext;
+ const addCommand = directoryCommand.subCommands?.find(
+ (c) => c.name === 'add',
+ );
+ const showCommand = directoryCommand.subCommands?.find(
+ (c) => c.name === 'show',
+ );
+
+ beforeEach(() => {
+ mockWorkspaceContext = {
+ addDirectory: vi.fn(),
+ getDirectories: vi
+ .fn()
+ .mockReturnValue([
+ path.normalize('/home/user/project1'),
+ path.normalize('/home/user/project2'),
+ ]),
+ } as unknown as WorkspaceContext;
+
+ mockConfig = {
+ getWorkspaceContext: () => mockWorkspaceContext,
+ isRestrictiveSandbox: vi.fn().mockReturnValue(false),
+ getGeminiClient: vi.fn().mockReturnValue({
+ addDirectoryContext: vi.fn(),
+ }),
+ } as unknown as Config;
+
+ mockContext = {
+ services: {
+ config: mockConfig,
+ },
+ ui: {
+ addItem: vi.fn(),
+ },
+ } as unknown as CommandContext;
+ });
+
+ describe('show', () => {
+ it('should display the list of directories', () => {
+ if (!showCommand?.action) throw new Error('No action');
+ showCommand.action(mockContext, '');
+ expect(mockWorkspaceContext.getDirectories).toHaveBeenCalled();
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageType.INFO,
+ text: `Current workspace directories:\n- ${path.normalize(
+ '/home/user/project1',
+ )}\n- ${path.normalize('/home/user/project2')}`,
+ }),
+ expect.any(Number),
+ );
+ });
+ });
+
+ describe('add', () => {
+ it('should show an error if no path is provided', () => {
+ if (!addCommand?.action) throw new Error('No action');
+ addCommand.action(mockContext, '');
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageType.ERROR,
+ text: 'Please provide at least one path to add.',
+ }),
+ expect.any(Number),
+ );
+ });
+
+ it('should call addDirectory and show a success message for a single path', async () => {
+ const newPath = path.normalize('/home/user/new-project');
+ if (!addCommand?.action) throw new Error('No action');
+ await addCommand.action(mockContext, newPath);
+ expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageType.INFO,
+ text: `Successfully added directories:\n- ${newPath}`,
+ }),
+ expect.any(Number),
+ );
+ });
+
+ it('should call addDirectory for each path and show a success message for multiple paths', async () => {
+ const newPath1 = path.normalize('/home/user/new-project1');
+ const newPath2 = path.normalize('/home/user/new-project2');
+ if (!addCommand?.action) throw new Error('No action');
+ await addCommand.action(mockContext, `${newPath1},${newPath2}`);
+ expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1);
+ expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2);
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageType.INFO,
+ text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`,
+ }),
+ expect.any(Number),
+ );
+ });
+
+ it('should show an error if addDirectory throws an exception', async () => {
+ const error = new Error('Directory does not exist');
+ vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => {
+ throw error;
+ });
+ const newPath = path.normalize('/home/user/invalid-project');
+ if (!addCommand?.action) throw new Error('No action');
+ await addCommand.action(mockContext, newPath);
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageType.ERROR,
+ text: `Error adding '${newPath}': ${error.message}`,
+ }),
+ expect.any(Number),
+ );
+ });
+
+ it('should handle a mix of successful and failed additions', async () => {
+ const validPath = path.normalize('/home/user/valid-project');
+ const invalidPath = path.normalize('/home/user/invalid-project');
+ const error = new Error('Directory does not exist');
+ vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
+ (p: string) => {
+ if (p === invalidPath) {
+ throw error;
+ }
+ },
+ );
+
+ if (!addCommand?.action) throw new Error('No action');
+ await addCommand.action(mockContext, `${validPath},${invalidPath}`);
+
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageType.INFO,
+ text: `Successfully added directories:\n- ${validPath}`,
+ }),
+ expect.any(Number),
+ );
+
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageType.ERROR,
+ text: `Error adding '${invalidPath}': ${error.message}`,
+ }),
+ expect.any(Number),
+ );
+ });
+ });
+ it('should correctly expand a Windows-style home directory path', () => {
+ const windowsPath = '%userprofile%\\Documents';
+ const expectedPath = path.win32.join(os.homedir(), 'Documents');
+ const result = expandHomeDir(windowsPath);
+ expect(path.win32.normalize(result)).toBe(
+ path.win32.normalize(expectedPath),
+ );
+ });
+});
diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx
new file mode 100644
index 00000000..18f7e78f
--- /dev/null
+++ b/packages/cli/src/ui/commands/directoryCommand.tsx
@@ -0,0 +1,150 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { SlashCommand, CommandContext, CommandKind } from './types.js';
+import { MessageType } from '../types.js';
+import * as os from 'os';
+import * as path from 'path';
+
+export function expandHomeDir(p: string): string {
+ if (!p) {
+ return '';
+ }
+ let expandedPath = p;
+ if (p.toLowerCase().startsWith('%userprofile%')) {
+ expandedPath = os.homedir() + p.substring('%userprofile%'.length);
+ } else if (p.startsWith('~')) {
+ expandedPath = os.homedir() + p.substring(1);
+ }
+ return path.normalize(expandedPath);
+}
+
+export const directoryCommand: SlashCommand = {
+ name: 'directory',
+ altNames: ['dir'],
+ description: 'Manage workspace directories',
+ kind: CommandKind.BUILT_IN,
+ subCommands: [
+ {
+ name: 'add',
+ description:
+ 'Add directories to the workspace. Use comma to separate multiple paths',
+ kind: CommandKind.BUILT_IN,
+ action: async (context: CommandContext, args: string) => {
+ const {
+ ui: { addItem },
+ services: { config },
+ } = context;
+ const [...rest] = args.split(' ');
+
+ if (!config) {
+ addItem(
+ {
+ type: MessageType.ERROR,
+ text: 'Configuration is not available.',
+ },
+ Date.now(),
+ );
+ return;
+ }
+
+ const workspaceContext = config.getWorkspaceContext();
+
+ const pathsToAdd = rest
+ .join(' ')
+ .split(',')
+ .filter((p) => p);
+ if (pathsToAdd.length === 0) {
+ addItem(
+ {
+ type: MessageType.ERROR,
+ text: 'Please provide at least one path to add.',
+ },
+ Date.now(),
+ );
+ return;
+ }
+
+ if (config.isRestrictiveSandbox()) {
+ return {
+ type: 'message' as const,
+ messageType: 'error' as const,
+ content:
+ 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
+ };
+ }
+
+ const added: string[] = [];
+ const errors: string[] = [];
+
+ for (const pathToAdd of pathsToAdd) {
+ try {
+ workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
+ added.push(pathToAdd.trim());
+ } catch (e) {
+ const error = e as Error;
+ errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
+ }
+ }
+
+ if (added.length > 0) {
+ const gemini = config.getGeminiClient();
+ if (gemini) {
+ await gemini.addDirectoryContext();
+ }
+ addItem(
+ {
+ type: MessageType.INFO,
+ text: `Successfully added directories:\n- ${added.join('\n- ')}`,
+ },
+ Date.now(),
+ );
+ }
+
+ if (errors.length > 0) {
+ addItem(
+ {
+ type: MessageType.ERROR,
+ text: errors.join('\n'),
+ },
+ Date.now(),
+ );
+ }
+ },
+ },
+ {
+ name: 'show',
+ description: 'Show all directories in the workspace',
+ kind: CommandKind.BUILT_IN,
+ action: async (context: CommandContext) => {
+ const {
+ ui: { addItem },
+ services: { config },
+ } = context;
+ if (!config) {
+ addItem(
+ {
+ type: MessageType.ERROR,
+ text: 'Configuration is not available.',
+ },
+ Date.now(),
+ );
+ return;
+ }
+ const workspaceContext = config.getWorkspaceContext();
+ const directories = workspaceContext.getDirectories();
+ const directoryList = directories.map((dir) => `- ${dir}`).join('\n');
+ addItem(
+ {
+ type: MessageType.INFO,
+ text: `Current workspace directories:\n${directoryList}`,
+ },
+ Date.now(),
+ );
+ },
+ },
+ ],
+};
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index edb24351..b2d5f387 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -197,7 +197,7 @@ export class Config {
private readonly embeddingModel: string;
private readonly sandbox: SandboxConfig | undefined;
private readonly targetDir: string;
- private readonly workspaceContext: WorkspaceContext;
+ private workspaceContext: WorkspaceContext;
private readonly debugMode: boolean;
private readonly question: string | undefined;
private readonly fullContext: boolean;
@@ -394,6 +394,17 @@ export class Config {
return this.sandbox;
}
+ isRestrictiveSandbox(): boolean {
+ const sandboxConfig = this.getSandbox();
+ const seatbeltProfile = process.env.SEATBELT_PROFILE;
+ return (
+ !!sandboxConfig &&
+ sandboxConfig.command === 'sandbox-exec' &&
+ !!seatbeltProfile &&
+ seatbeltProfile.startsWith('restrictive-')
+ );
+ }
+
getTargetDir(): string {
return this.targetDir;
}
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index 5b26e32c..be105971 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -171,6 +171,35 @@ export class GeminiClient {
this.chat = await this.startChat();
}
+ async addDirectoryContext(): Promise<void> {
+ if (!this.chat) {
+ return;
+ }
+
+ this.getChat().addHistory({
+ role: 'user',
+ parts: [{ text: await this.getDirectoryContext() }],
+ });
+ }
+
+ private async getDirectoryContext(): Promise<string> {
+ const workspaceContext = this.config.getWorkspaceContext();
+ const workspaceDirectories = workspaceContext.getDirectories();
+
+ const folderStructures = await Promise.all(
+ workspaceDirectories.map((dir) =>
+ getFolderStructure(dir, {
+ fileService: this.config.getFileService(),
+ }),
+ ),
+ );
+
+ const folderStructure = folderStructures.join('\n');
+ const dirList = workspaceDirectories.map((dir) => ` - ${dir}`).join('\n');
+ const workingDirPreamble = `I'm currently working in the following directories:\n${dirList}\n Folder structures are as follows:\n${folderStructure}`;
+ return workingDirPreamble;
+ }
+
private async getEnvironment(): Promise<Part[]> {
const today = new Date().toLocaleDateString(undefined, {
weekday: 'long',
@@ -208,6 +237,7 @@ export class GeminiClient {
Today's date is ${today}.
My operating system is: ${platform}
${workingDirPreamble}
+ Here is the folder structure of the current working directories:\n
${folderStructure}
`.trim();