diff --git a/docs/extension.md b/docs/extension.md index 8816133c29..34595c027a 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -153,4 +153,5 @@ Gemini CLI extensions allow variable substitution in `gemini-extension.json`. Th | 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. | +| `${workspacePath}` | The fully-qualified path of the current workspace. | | `${/} 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 a94c410fd9..c22c6340cc 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -743,7 +743,10 @@ describe('extension tests', () => { }); const failed = await performWorkspaceExtensionMigration([ - loadExtension(ext1Path)!, + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, ]); expect(failed).toEqual(['ext1']); @@ -757,7 +760,12 @@ describe('extension tests', () => { version: '1.0.0', }); - await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); + await performWorkspaceExtensionMigration([ + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, + ]); const userExtensionsDir = path.join( tempHomeDir, @@ -775,7 +783,12 @@ describe('extension tests', () => { version: '1.0.0', }); - await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); + await performWorkspaceExtensionMigration([ + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, + ]); const extensions = loadExtensions(); expect(extensions).toEqual([]); @@ -794,8 +807,14 @@ describe('extension tests', () => { version: '1.0.0', }); const extensionsToMigrate: Extension[] = [ - loadExtension(ext1Path)!, - loadExtension(ext2Path)!, + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, + loadExtension({ + extensionDir: ext2Path, + workspaceDir: tempWorkspaceDir, + })!, ]; const failed = await performWorkspaceExtensionMigration(extensionsToMigrate); @@ -828,7 +847,10 @@ describe('extension tests', () => { }); const extensions: Extension[] = [ - loadExtension(ext1Path)!, + loadExtension({ + extensionDir: ext1Path, + workspaceDir: tempWorkspaceDir, + })!, { path: '/ext/path/1', config: { name: 'ext2', version: '1.0.0' }, @@ -869,7 +891,12 @@ describe('extension tests', () => { }); mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); const extension = annotateActiveExtensions( - [loadExtension(targetExtDir)!], + [ + loadExtension({ + extensionDir: targetExtDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -920,7 +947,12 @@ describe('extension tests', () => { const setExtensionUpdateState = vi.fn(); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -951,7 +983,12 @@ describe('extension tests', () => { const setExtensionUpdateState = vi.fn(); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -980,7 +1017,12 @@ describe('extension tests', () => { }, }); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -1007,7 +1049,12 @@ describe('extension tests', () => { }, }); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -1031,7 +1078,12 @@ describe('extension tests', () => { installMetadata: { source: '/local/path', type: 'local' }, }); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -1052,7 +1104,12 @@ describe('extension tests', () => { }, }); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -1077,7 +1134,12 @@ describe('extension tests', () => { }, }); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -1103,7 +1165,12 @@ describe('extension tests', () => { }, }); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -1125,7 +1192,12 @@ describe('extension tests', () => { version: '1.0.0', }); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; @@ -1145,7 +1217,12 @@ describe('extension tests', () => { }, }); const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], + [ + loadExtension({ + extensionDir, + workspaceDir: tempWorkspaceDir, + })!, + ], [], process.cwd(), )[0]; diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 17974ac840..cbaf155fb8 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -27,6 +27,7 @@ import { isWorkspaceTrusted } from './trustedFolders.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { randomUUID } from 'node:crypto'; import { ExtensionUpdateState } from '../ui/state/extensions.js'; +import type { LoadExtensionContext } from './extensions/variableSchema.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); @@ -192,7 +193,7 @@ export function loadExtensionsFromDir(dir: string): Extension[] { for (const subdir of fs.readdirSync(extensionsDir)) { const extensionDir = path.join(extensionsDir, subdir); - const extension = loadExtension(extensionDir); + const extension = loadExtension({ extensionDir, workspaceDir: dir }); if (extension != null) { extensions.push(extension); } @@ -200,7 +201,8 @@ export function loadExtensionsFromDir(dir: string): Extension[] { return extensions; } -export function loadExtension(extensionDir: string): Extension | null { +export function loadExtension(context: LoadExtensionContext): Extension | null { + const { extensionDir, workspaceDir } = context; if (!fs.statSync(extensionDir).isDirectory()) { return null; } @@ -227,6 +229,7 @@ export function loadExtension(extensionDir: string): Extension | null { const configContent = fs.readFileSync(configFilePath, 'utf-8'); let config = recursivelyHydrateStrings(JSON.parse(configContent), { extensionPath: extensionDir, + workspacePath: workspaceDir, '/': path.sep, pathSeparator: path.sep, }) as unknown as ExtensionConfig; @@ -455,7 +458,10 @@ export async function installExtension( } try { - newExtensionConfig = await loadExtensionConfig(localSourcePath); + newExtensionConfig = await loadExtensionConfig({ + extensionDir: localSourcePath, + workspaceDir: cwd, + }); if (!newExtensionConfig) { throw new Error( `Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`, @@ -533,7 +539,10 @@ export async function installExtension( // Attempt to load config from the source path even if installation fails // to get the name and version for logging. if (!newExtensionConfig && localSourcePath) { - newExtensionConfig = await loadExtensionConfig(localSourcePath); + newExtensionConfig = await loadExtensionConfig({ + extensionDir: localSourcePath, + workspaceDir: cwd, + }); } logger?.logExtensionInstallEvent( new ExtensionInstallEvent( @@ -548,8 +557,9 @@ export async function installExtension( } export async function loadExtensionConfig( - extensionDir: string, + context: LoadExtensionContext, ): Promise { + const { extensionDir, workspaceDir } = context; const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); if (!fs.existsSync(configFilePath)) { return null; @@ -558,6 +568,7 @@ export async function loadExtensionConfig( const configContent = fs.readFileSync(configFilePath, 'utf-8'); const config = recursivelyHydrateStrings(JSON.parse(configContent), { extensionPath: extensionDir, + workspacePath: workspaceDir, '/': path.sep, pathSeparator: path.sep, }) as unknown as ExtensionConfig; @@ -677,9 +688,10 @@ export async function updateExtension( ); const updatedExtensionStorage = new ExtensionStorage(extension.name); - const updatedExtension = loadExtension( - updatedExtensionStorage.getExtensionDir(), - ); + const updatedExtension = loadExtension({ + extensionDir: updatedExtensionStorage.getExtensionDir(), + workspaceDir: cwd, + }); if (!updatedExtension) { setExtensionUpdateState(ExtensionUpdateState.ERROR); throw new Error('Updated extension not found after installation.'); diff --git a/packages/cli/src/config/extensions/variableSchema.ts b/packages/cli/src/config/extensions/variableSchema.ts index e55f2a5258..f38e1b1f81 100644 --- a/packages/cli/src/config/extensions/variableSchema.ts +++ b/packages/cli/src/config/extensions/variableSchema.ts @@ -15,6 +15,11 @@ export interface VariableSchema { [key: string]: VariableDefinition; } +export interface LoadExtensionContext { + extensionDir: string; + workspaceDir: string; +} + const PATH_SEPARATOR_DEFINITION = { type: 'string', description: 'The path separator.', @@ -25,6 +30,10 @@ export const VARIABLE_SCHEMA = { type: 'string', description: 'The path of the extension in the filesystem.', }, + workspacePath: { + type: 'string', + description: 'The absolute path of the current workspace.', + }, '/': PATH_SEPARATOR_DEFINITION, pathSeparator: PATH_SEPARATOR_DEFINITION, } as const;