From b6cca011619a1a539ab7fae4681be2fddff6a320 Mon Sep 17 00:00:00 2001 From: christine betts Date: Tue, 26 Aug 2025 02:13:16 +0000 Subject: [PATCH] [extensions] Add an initial set of extension variables (#7035) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/extension.md | 11 ++++ packages/cli/src/config/extension.test.ts | 40 ++++++++++-- packages/cli/src/config/extension.ts | 7 +- .../src/config/extensions/variableSchema.ts | 30 +++++++++ .../src/config/extensions/variables.test.ts | 18 +++++ .../cli/src/config/extensions/variables.ts | 65 +++++++++++++++++++ 6 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/config/extensions/variableSchema.ts create mode 100644 packages/cli/src/config/extensions/variables.test.ts create mode 100644 packages/cli/src/config/extensions/variables.ts diff --git a/docs/extension.md b/docs/extension.md index f00c24a5..8c5f0a1a 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -87,3 +87,14 @@ You can install extensions using the `install` command. This command allows you - `source positional argument`: The URL of a Git repository to install the extension from. The repository must contain a `gemini-extension.json` file in its root. - `--path `: The path to a local directory to install as an extension. The directory must contain a `gemini-extension.json` file. + +# Variables + +Gemini CLI extensions allow variable substitution in `gemini-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`. + +**Supported variables:** + +| variable | description | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. | +| `${/} or ${pathSeparator}` | The path separator (differs per OS). | diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index ad6d7d1f..6c589bc0 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -17,6 +17,7 @@ import { uninstallExtension, updateExtension, } from './extension.js'; +import { type MCPServerConfig } from '@google/gemini-cli-core'; import { execSync } from 'node:child_process'; import { type SimpleGit, simpleGit } from 'simple-git'; @@ -58,6 +59,7 @@ describe('loadExtensions', () => { afterEach(() => { fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); fs.rmSync(tempHomeDir, { recursive: true, force: true }); + vi.restoreAllMocks(); }); it('should include extension path in loaded extension', () => { @@ -127,6 +129,37 @@ describe('loadExtensions', () => { path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'), ]); }); + + it('should hydrate variables', () => { + const workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + + createExtension( + workspaceExtensionsDir, + 'test-extension', + '1.0.0', + false, + undefined, + { + 'test-server': { + cwd: '${extensionPath}${/}server', + }, + }, + ); + + const extensions = loadExtensions(tempWorkspaceDir); + expect(extensions).toHaveLength(1); + const loadedConfig = extensions[0].config; + const expectedCwd = path.join( + workspaceExtensionsDir, + 'test-extension', + 'server', + ); + expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd); + }); }); describe('annotateActiveExtensions', () => { @@ -265,9 +298,7 @@ describe('installExtension', () => { }); const mockedSimpleGit = simpleGit as vi.MockedFunction; - mockedSimpleGit.mockReturnValue({ - clone, - } as unknown as SimpleGit); + mockedSimpleGit.mockReturnValue({ clone } as unknown as SimpleGit); await installExtension({ source: gitUrl, type: 'git' }); @@ -347,12 +378,13 @@ function createExtension( version: string, addContextFile = false, contextFileName?: string, + mcpServers?: Record, ): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName }), + JSON.stringify({ name, version, contextFileName, mcpServers }), ); if (addContextFile) { diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index b894e7fc..4d37be2c 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -13,6 +13,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { simpleGit } from 'simple-git'; +import { recursivelyHydrateStrings } from './extensions/variables.js'; export const EXTENSIONS_DIRECTORY_NAME = '.gemini/extensions'; @@ -144,7 +145,11 @@ export function loadExtension(extensionDir: string): Extension | null { try { const configContent = fs.readFileSync(configFilePath, 'utf-8'); - const config = JSON.parse(configContent) as ExtensionConfig; + const config = recursivelyHydrateStrings(JSON.parse(configContent), { + extensionPath: extensionDir, + '/': path.sep, + pathSeparator: path.sep, + }) as unknown as ExtensionConfig; if (!config.name || !config.version) { console.error( `Invalid extension config in ${configFilePath}: missing name or version.`, diff --git a/packages/cli/src/config/extensions/variableSchema.ts b/packages/cli/src/config/extensions/variableSchema.ts new file mode 100644 index 00000000..e55f2a52 --- /dev/null +++ b/packages/cli/src/config/extensions/variableSchema.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface VariableDefinition { + type: 'string'; + description: string; + default?: string; + required?: boolean; +} + +export interface VariableSchema { + [key: string]: VariableDefinition; +} + +const PATH_SEPARATOR_DEFINITION = { + type: 'string', + description: 'The path separator.', +} as const; + +export const VARIABLE_SCHEMA = { + extensionPath: { + type: 'string', + description: 'The path of the extension in the filesystem.', + }, + '/': PATH_SEPARATOR_DEFINITION, + pathSeparator: PATH_SEPARATOR_DEFINITION, +} as const; diff --git a/packages/cli/src/config/extensions/variables.test.ts b/packages/cli/src/config/extensions/variables.test.ts new file mode 100644 index 00000000..d2015f4f --- /dev/null +++ b/packages/cli/src/config/extensions/variables.test.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it } from 'vitest'; +import { hydrateString } from './variables.js'; + +describe('hydrateString', () => { + it('should replace a single variable', () => { + const context = { + extensionPath: 'path/my-extension', + }; + const result = hydrateString('Hello, ${extensionPath}!', context); + expect(result).toBe('Hello, path/my-extension!'); + }); +}); diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts new file mode 100644 index 00000000..7c6ef846 --- /dev/null +++ b/packages/cli/src/config/extensions/variables.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js'; + +export type JsonObject = { [key: string]: JsonValue }; +export type JsonArray = JsonValue[]; +export type JsonValue = + | string + | number + | boolean + | null + | JsonObject + | JsonArray; + +export type VariableContext = { + [key in keyof typeof VARIABLE_SCHEMA]?: string; +}; + +export function validateVariables( + variables: VariableContext, + schema: VariableSchema, +) { + for (const key in schema) { + const definition = schema[key]; + if (definition.required && !variables[key as keyof VariableContext]) { + throw new Error(`Missing required variable: ${key}`); + } + } +} + +export function hydrateString(str: string, context: VariableContext): string { + validateVariables(context, VARIABLE_SCHEMA); + const regex = /\${(.*?)}/g; + return str.replace(regex, (match, key) => + context[key as keyof VariableContext] == null + ? match + : (context[key as keyof VariableContext] as string), + ); +} + +export function recursivelyHydrateStrings( + obj: JsonValue, + values: VariableContext, +): JsonValue { + if (typeof obj === 'string') { + return hydrateString(obj, values); + } + if (Array.isArray(obj)) { + return obj.map((item) => recursivelyHydrateStrings(item, values)); + } + if (typeof obj === 'object' && obj !== null) { + const newObj: JsonObject = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + newObj[key] = recursivelyHydrateStrings(obj[key], values); + } + } + return newObj; + } + return obj; +}