fix(cli): add workspacePath to extension variables (#8482)

Co-authored-by: Taneja Hriday <hridayt@google.com>
This commit is contained in:
hritan
2025-09-17 04:23:12 +00:00
committed by GitHub
parent f849c85608
commit d2f87d15ed
4 changed files with 124 additions and 25 deletions

View File

@@ -153,4 +153,5 @@ Gemini CLI extensions allow variable substitution in `gemini-extension.json`. Th
| variable | description | | 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. | | `${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). | | `${/} or ${pathSeparator}` | The path separator (differs per OS). |

View File

@@ -743,7 +743,10 @@ describe('extension tests', () => {
}); });
const failed = await performWorkspaceExtensionMigration([ const failed = await performWorkspaceExtensionMigration([
loadExtension(ext1Path)!, loadExtension({
extensionDir: ext1Path,
workspaceDir: tempWorkspaceDir,
})!,
]); ]);
expect(failed).toEqual(['ext1']); expect(failed).toEqual(['ext1']);
@@ -757,7 +760,12 @@ describe('extension tests', () => {
version: '1.0.0', version: '1.0.0',
}); });
await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); await performWorkspaceExtensionMigration([
loadExtension({
extensionDir: ext1Path,
workspaceDir: tempWorkspaceDir,
})!,
]);
const userExtensionsDir = path.join( const userExtensionsDir = path.join(
tempHomeDir, tempHomeDir,
@@ -775,7 +783,12 @@ describe('extension tests', () => {
version: '1.0.0', version: '1.0.0',
}); });
await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); await performWorkspaceExtensionMigration([
loadExtension({
extensionDir: ext1Path,
workspaceDir: tempWorkspaceDir,
})!,
]);
const extensions = loadExtensions(); const extensions = loadExtensions();
expect(extensions).toEqual([]); expect(extensions).toEqual([]);
@@ -794,8 +807,14 @@ describe('extension tests', () => {
version: '1.0.0', version: '1.0.0',
}); });
const extensionsToMigrate: Extension[] = [ const extensionsToMigrate: Extension[] = [
loadExtension(ext1Path)!, loadExtension({
loadExtension(ext2Path)!, extensionDir: ext1Path,
workspaceDir: tempWorkspaceDir,
})!,
loadExtension({
extensionDir: ext2Path,
workspaceDir: tempWorkspaceDir,
})!,
]; ];
const failed = const failed =
await performWorkspaceExtensionMigration(extensionsToMigrate); await performWorkspaceExtensionMigration(extensionsToMigrate);
@@ -828,7 +847,10 @@ describe('extension tests', () => {
}); });
const extensions: Extension[] = [ const extensions: Extension[] = [
loadExtension(ext1Path)!, loadExtension({
extensionDir: ext1Path,
workspaceDir: tempWorkspaceDir,
})!,
{ {
path: '/ext/path/1', path: '/ext/path/1',
config: { name: 'ext2', version: '1.0.0' }, config: { name: 'ext2', version: '1.0.0' },
@@ -869,7 +891,12 @@ describe('extension tests', () => {
}); });
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(targetExtDir)!], [
loadExtension({
extensionDir: targetExtDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -920,7 +947,12 @@ describe('extension tests', () => {
const setExtensionUpdateState = vi.fn(); const setExtensionUpdateState = vi.fn();
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -951,7 +983,12 @@ describe('extension tests', () => {
const setExtensionUpdateState = vi.fn(); const setExtensionUpdateState = vi.fn();
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -980,7 +1017,12 @@ describe('extension tests', () => {
}, },
}); });
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -1007,7 +1049,12 @@ describe('extension tests', () => {
}, },
}); });
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -1031,7 +1078,12 @@ describe('extension tests', () => {
installMetadata: { source: '/local/path', type: 'local' }, installMetadata: { source: '/local/path', type: 'local' },
}); });
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -1052,7 +1104,12 @@ describe('extension tests', () => {
}, },
}); });
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -1077,7 +1134,12 @@ describe('extension tests', () => {
}, },
}); });
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -1103,7 +1165,12 @@ describe('extension tests', () => {
}, },
}); });
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -1125,7 +1192,12 @@ describe('extension tests', () => {
version: '1.0.0', version: '1.0.0',
}); });
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
@@ -1145,7 +1217,12 @@ describe('extension tests', () => {
}, },
}); });
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension(extensionDir)!], [
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];

View File

@@ -27,6 +27,7 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { ExtensionUpdateState } from '../ui/state/extensions.js'; import { ExtensionUpdateState } from '../ui/state/extensions.js';
import type { LoadExtensionContext } from './extensions/variableSchema.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); 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)) { for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir); const extensionDir = path.join(extensionsDir, subdir);
const extension = loadExtension(extensionDir); const extension = loadExtension({ extensionDir, workspaceDir: dir });
if (extension != null) { if (extension != null) {
extensions.push(extension); extensions.push(extension);
} }
@@ -200,7 +201,8 @@ export function loadExtensionsFromDir(dir: string): Extension[] {
return extensions; 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()) { if (!fs.statSync(extensionDir).isDirectory()) {
return null; return null;
} }
@@ -227,6 +229,7 @@ export function loadExtension(extensionDir: string): Extension | null {
const configContent = fs.readFileSync(configFilePath, 'utf-8'); const configContent = fs.readFileSync(configFilePath, 'utf-8');
let config = recursivelyHydrateStrings(JSON.parse(configContent), { let config = recursivelyHydrateStrings(JSON.parse(configContent), {
extensionPath: extensionDir, extensionPath: extensionDir,
workspacePath: workspaceDir,
'/': path.sep, '/': path.sep,
pathSeparator: path.sep, pathSeparator: path.sep,
}) as unknown as ExtensionConfig; }) as unknown as ExtensionConfig;
@@ -455,7 +458,10 @@ export async function installExtension(
} }
try { try {
newExtensionConfig = await loadExtensionConfig(localSourcePath); newExtensionConfig = await loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
});
if (!newExtensionConfig) { if (!newExtensionConfig) {
throw new Error( throw new Error(
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`, `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 // Attempt to load config from the source path even if installation fails
// to get the name and version for logging. // to get the name and version for logging.
if (!newExtensionConfig && localSourcePath) { if (!newExtensionConfig && localSourcePath) {
newExtensionConfig = await loadExtensionConfig(localSourcePath); newExtensionConfig = await loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
});
} }
logger?.logExtensionInstallEvent( logger?.logExtensionInstallEvent(
new ExtensionInstallEvent( new ExtensionInstallEvent(
@@ -548,8 +557,9 @@ export async function installExtension(
} }
export async function loadExtensionConfig( export async function loadExtensionConfig(
extensionDir: string, context: LoadExtensionContext,
): Promise<ExtensionConfig | null> { ): Promise<ExtensionConfig | null> {
const { extensionDir, workspaceDir } = context;
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) { if (!fs.existsSync(configFilePath)) {
return null; return null;
@@ -558,6 +568,7 @@ export async function loadExtensionConfig(
const configContent = fs.readFileSync(configFilePath, 'utf-8'); const configContent = fs.readFileSync(configFilePath, 'utf-8');
const config = recursivelyHydrateStrings(JSON.parse(configContent), { const config = recursivelyHydrateStrings(JSON.parse(configContent), {
extensionPath: extensionDir, extensionPath: extensionDir,
workspacePath: workspaceDir,
'/': path.sep, '/': path.sep,
pathSeparator: path.sep, pathSeparator: path.sep,
}) as unknown as ExtensionConfig; }) as unknown as ExtensionConfig;
@@ -677,9 +688,10 @@ export async function updateExtension(
); );
const updatedExtensionStorage = new ExtensionStorage(extension.name); const updatedExtensionStorage = new ExtensionStorage(extension.name);
const updatedExtension = loadExtension( const updatedExtension = loadExtension({
updatedExtensionStorage.getExtensionDir(), extensionDir: updatedExtensionStorage.getExtensionDir(),
); workspaceDir: cwd,
});
if (!updatedExtension) { if (!updatedExtension) {
setExtensionUpdateState(ExtensionUpdateState.ERROR); setExtensionUpdateState(ExtensionUpdateState.ERROR);
throw new Error('Updated extension not found after installation.'); throw new Error('Updated extension not found after installation.');

View File

@@ -15,6 +15,11 @@ export interface VariableSchema {
[key: string]: VariableDefinition; [key: string]: VariableDefinition;
} }
export interface LoadExtensionContext {
extensionDir: string;
workspaceDir: string;
}
const PATH_SEPARATOR_DEFINITION = { const PATH_SEPARATOR_DEFINITION = {
type: 'string', type: 'string',
description: 'The path separator.', description: 'The path separator.',
@@ -25,6 +30,10 @@ export const VARIABLE_SCHEMA = {
type: 'string', type: 'string',
description: 'The path of the extension in the filesystem.', description: 'The path of the extension in the filesystem.',
}, },
workspacePath: {
type: 'string',
description: 'The absolute path of the current workspace.',
},
'/': PATH_SEPARATOR_DEFINITION, '/': PATH_SEPARATOR_DEFINITION,
pathSeparator: PATH_SEPARATOR_DEFINITION, pathSeparator: PATH_SEPARATOR_DEFINITION,
} as const; } as const;