Load extension settings for hooks, agents, skills (#17245)

This commit is contained in:
christine betts
2026-01-27 14:34:14 -05:00
committed by GitHub
parent 36d618f72a
commit 9dc0994878
5 changed files with 398 additions and 25 deletions
@@ -0,0 +1,318 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
homedir: mockHomedir,
};
});
// Mock @google/gemini-cli-core
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
homedir: mockHomedir,
// Use actual implementations for loading skills and agents to test hydration
loadAgentsFromDirectory: actual.loadAgentsFromDirectory,
loadSkillsFromDir: actual.loadSkillsFromDir,
};
});
describe('ExtensionManager hydration', () => {
let extensionManager: ExtensionManager;
let tempDir: string;
let extensionsDir: string;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(coreEvents, 'emitFeedback');
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
mockHomedir.mockReturnValue(tempDir);
// Create the extensions directory that ExtensionManager expects
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
fs.mkdirSync(extensionsDir, { recursive: true });
extensionManager = new ExtensionManager({
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
});
});
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
});
it('should hydrate skill body with extension settings', async () => {
const sourceDir = path.join(tempDir, 'source-ext-skill');
const extensionName = 'skill-hydration-ext';
createExtension({
extensionsDir: sourceDir,
name: extensionName,
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'API Key',
envVar: 'MY_API_KEY',
},
],
installMetadata: {
type: 'local',
source: path.join(sourceDir, extensionName),
},
});
const extensionPath = path.join(sourceDir, extensionName);
// Create skill with variable
const skillsDir = path.join(extensionPath, 'skills');
const skillSubdir = path.join(skillsDir, 'my-skill');
fs.mkdirSync(skillSubdir, { recursive: true });
fs.writeFileSync(
path.join(skillSubdir, 'SKILL.md'),
`---
name: my-skill
description: test
---
Use key: \${MY_API_KEY}
`,
);
await extensionManager.loadExtensions();
extensionManager.setRequestSetting(async (setting) => {
if (setting.envVar === 'MY_API_KEY') return 'secret-123';
return '';
});
const extension = await extensionManager.installOrUpdateExtension({
type: 'local',
source: extensionPath,
});
expect(extension.skills).toHaveLength(1);
expect(extension.skills![0].body).toContain('Use key: secret-123');
});
it('should hydrate agent system prompt with extension settings', async () => {
const sourceDir = path.join(tempDir, 'source-ext-agent');
const extensionName = 'agent-hydration-ext';
createExtension({
extensionsDir: sourceDir,
name: extensionName,
version: '1.0.0',
settings: [
{
name: 'Model Name',
description: 'Model',
envVar: 'MODEL_NAME',
},
],
installMetadata: {
type: 'local',
source: path.join(sourceDir, extensionName),
},
});
const extensionPath = path.join(sourceDir, extensionName);
// Create agent with variable
const agentsDir = path.join(extensionPath, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
fs.writeFileSync(
path.join(agentsDir, 'my-agent.md'),
`---
name: my-agent
description: test
---
System using model: \${MODEL_NAME}
`,
);
await extensionManager.loadExtensions();
extensionManager.setRequestSetting(async (setting) => {
if (setting.envVar === 'MODEL_NAME') return 'gemini-pro';
return '';
});
const extension = await extensionManager.installOrUpdateExtension({
type: 'local',
source: extensionPath,
});
expect(extension.agents).toHaveLength(1);
const agent = extension.agents![0];
if (agent.kind === 'local') {
expect(agent.promptConfig.systemPrompt).toContain(
'System using model: gemini-pro',
);
} else {
throw new Error('Expected local agent');
}
});
it('should hydrate hooks with extension settings', async () => {
const sourceDir = path.join(tempDir, 'source-ext-hooks');
const extensionName = 'hooks-hydration-ext';
createExtension({
extensionsDir: sourceDir,
name: extensionName,
version: '1.0.0',
settings: [
{
name: 'Hook Command',
description: 'Cmd',
envVar: 'HOOK_CMD',
},
],
installMetadata: {
type: 'local',
source: path.join(sourceDir, extensionName),
},
});
const extensionPath = path.join(sourceDir, extensionName);
const hooksDir = path.join(extensionPath, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
fs.writeFileSync(
path.join(hooksDir, 'hooks.json'),
JSON.stringify({
hooks: {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: 'echo $HOOK_CMD',
},
],
},
],
},
}),
);
// Enable hooks in settings
extensionManager = new ExtensionManager({
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
tools: { enableHooks: true },
hooksConfig: { enabled: true },
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
});
await extensionManager.loadExtensions();
extensionManager.setRequestSetting(async (setting) => {
if (setting.envVar === 'HOOK_CMD') return 'hello-world';
return '';
});
const extension = await extensionManager.installOrUpdateExtension({
type: 'local',
source: extensionPath,
});
expect(extension.hooks).toBeDefined();
expect(extension.hooks?.BeforeTool).toHaveLength(1);
expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
'hello-world',
);
});
it('should pick up new settings after restartExtension', async () => {
const sourceDir = path.join(tempDir, 'source-ext-restart');
const extensionName = 'restart-hydration-ext';
createExtension({
extensionsDir: sourceDir,
name: extensionName,
version: '1.0.0',
settings: [
{
name: 'Value',
description: 'Val',
envVar: 'MY_VALUE',
},
],
installMetadata: {
type: 'local',
source: path.join(sourceDir, extensionName),
},
});
const extensionPath = path.join(sourceDir, extensionName);
const skillsDir = path.join(extensionPath, 'skills');
const skillSubdir = path.join(skillsDir, 'my-skill');
fs.mkdirSync(skillSubdir, { recursive: true });
fs.writeFileSync(
path.join(skillSubdir, 'SKILL.md'),
'---\nname: my-skill\ndescription: test\n---\nValue is: ${MY_VALUE}',
);
await extensionManager.loadExtensions();
// Initial setting
extensionManager.setRequestSetting(async () => 'first');
const extension = await extensionManager.installOrUpdateExtension({
type: 'local',
source: extensionPath,
});
expect(extension.skills![0].body).toContain('Value is: first');
const { updateSetting, ExtensionSettingScope } = await import(
'./extensions/extensionSettings.js'
);
const extensionConfig =
await extensionManager.loadExtensionConfig(extensionPath);
const mockRequestSetting = vi.fn().mockResolvedValue('second');
await updateSetting(
extensionConfig,
extension.id,
'MY_VALUE',
mockRequestSetting,
ExtensionSettingScope.USER,
);
await extensionManager.restartExtension(extension);
const reloadedExtension = extensionManager
.getExtensions()
.find((e) => e.name === extensionName)!;
expect(reloadedExtension.skills![0].body).toContain('Value is: second');
});
});
+62 -12
View File
@@ -57,6 +57,7 @@ import {
INSTALL_METADATA_FILENAME, INSTALL_METADATA_FILENAME,
recursivelyHydrateStrings, recursivelyHydrateStrings,
type JsonObject, type JsonObject,
type VariableContext,
} from './extensions/variables.js'; } from './extensions/variables.js';
import { import {
getEnvContents, getEnvContents,
@@ -538,12 +539,14 @@ Would you like to attempt to install via "git clone" instead?`,
extensionId, extensionId,
ExtensionSettingScope.USER, ExtensionSettingScope.USER,
); );
workspaceSettings = await getScopedEnvContents( if (isWorkspaceTrusted(this.settings).isTrusted) {
config, workspaceSettings = await getScopedEnvContents(
extensionId, config,
ExtensionSettingScope.WORKSPACE, extensionId,
this.workspaceDir, ExtensionSettingScope.WORKSPACE,
); this.workspaceDir,
);
}
} }
const customEnv = { ...userSettings, ...workspaceSettings }; const customEnv = { ...userSettings, ...workspaceSettings };
@@ -612,24 +615,63 @@ Would you like to attempt to install via "git clone" instead?`,
) )
.filter((contextFilePath) => fs.existsSync(contextFilePath)); .filter((contextFilePath) => fs.existsSync(contextFilePath));
const hydrationContext: VariableContext = {
extensionPath: effectiveExtensionPath,
workspacePath: this.workspaceDir,
'/': path.sep,
pathSeparator: path.sep,
...customEnv,
};
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if ( if (
this.settings.tools.enableHooks && this.settings.tools.enableHooks &&
this.settings.hooksConfig.enabled this.settings.hooksConfig.enabled
) { ) {
hooks = await this.loadExtensionHooks(effectiveExtensionPath, { hooks = await this.loadExtensionHooks(
extensionPath: effectiveExtensionPath, effectiveExtensionPath,
workspacePath: this.workspaceDir, hydrationContext,
}); );
} }
const skills = await loadSkillsFromDir( // Hydrate hooks with extension settings as environment variables
if (hooks && config.settings) {
const hookEnv: Record<string, string> = {};
for (const setting of config.settings) {
const value = customEnv[setting.envVar];
if (value !== undefined) {
hookEnv[setting.envVar] = value;
}
}
if (Object.keys(hookEnv).length > 0) {
for (const eventName of Object.keys(hooks)) {
const eventHooks = hooks[eventName as HookEventName];
if (eventHooks) {
for (const definition of eventHooks) {
for (const hook of definition.hooks) {
// Merge existing env with new env vars, giving extension settings precedence.
hook.env = { ...hook.env, ...hookEnv };
}
}
}
}
}
}
let skills = await loadSkillsFromDir(
path.join(effectiveExtensionPath, 'skills'), path.join(effectiveExtensionPath, 'skills'),
); );
skills = skills.map((skill) =>
recursivelyHydrateStrings(skill, hydrationContext),
);
const agentLoadResult = await loadAgentsFromDirectory( const agentLoadResult = await loadAgentsFromDirectory(
path.join(effectiveExtensionPath, 'agents'), path.join(effectiveExtensionPath, 'agents'),
); );
agentLoadResult.agents = agentLoadResult.agents.map((agent) =>
recursivelyHydrateStrings(agent, hydrationContext),
);
// Log errors but don't fail the entire extension load // Log errors but don't fail the entire extension load
for (const error of agentLoadResult.errors) { for (const error of agentLoadResult.errors) {
@@ -671,6 +713,14 @@ Would you like to attempt to install via "git clone" instead?`,
} }
} }
override async restartExtension(
extension: GeminiCLIExtension,
): Promise<void> {
const extensionDir = extension.path;
await this.unloadExtension(extension);
await this.loadExtension(extensionDir);
}
/** /**
* Removes `extension` from the list of extensions and stops it if * Removes `extension` from the list of extensions and stops it if
* appropriate. * appropriate.
@@ -720,7 +770,7 @@ Would you like to attempt to install via "git clone" instead?`,
private async loadExtensionHooks( private async loadExtensionHooks(
extensionDir: string, extensionDir: string,
context: { extensionPath: string; workspacePath: string }, context: VariableContext,
): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> { ): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> {
const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json'); const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json');
+16 -13
View File
@@ -24,7 +24,7 @@ export type JsonValue =
| JsonArray; | JsonArray;
export type VariableContext = { export type VariableContext = {
[key in keyof typeof VARIABLE_SCHEMA]?: string; [key: string]: string | undefined;
}; };
export function validateVariables( export function validateVariables(
@@ -33,7 +33,7 @@ export function validateVariables(
) { ) {
for (const key in schema) { for (const key in schema) {
const definition = schema[key]; const definition = schema[key];
if (definition.required && !variables[key as keyof VariableContext]) { if (definition.required && !variables[key]) {
throw new Error(`Missing required variable: ${key}`); throw new Error(`Missing required variable: ${key}`);
} }
} }
@@ -43,30 +43,33 @@ export function hydrateString(str: string, context: VariableContext): string {
validateVariables(context, VARIABLE_SCHEMA); validateVariables(context, VARIABLE_SCHEMA);
const regex = /\${(.*?)}/g; const regex = /\${(.*?)}/g;
return str.replace(regex, (match, key) => return str.replace(regex, (match, key) =>
context[key as keyof VariableContext] == null context[key] == null ? match : context[key],
? match
: (context[key as keyof VariableContext] as string),
); );
} }
export function recursivelyHydrateStrings( export function recursivelyHydrateStrings<T>(
obj: JsonValue, obj: T,
values: VariableContext, values: VariableContext,
): JsonValue { ): T {
if (typeof obj === 'string') { if (typeof obj === 'string') {
return hydrateString(obj, values); return hydrateString(obj, values) as unknown as T;
} }
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map((item) => recursivelyHydrateStrings(item, values)); return obj.map((item) =>
recursivelyHydrateStrings(item, values),
) as unknown as T;
} }
if (typeof obj === 'object' && obj !== null) { if (typeof obj === 'object' && obj !== null) {
const newObj: JsonObject = {}; const newObj: Record<string, unknown> = {};
for (const key in obj) { for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) { if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = recursivelyHydrateStrings(obj[key], values); newObj[key] = recursivelyHydrateStrings(
(obj as Record<string, unknown>)[key],
values,
);
} }
} }
return newObj; return newObj as T;
} }
return obj; return obj;
} }
+1
View File
@@ -267,6 +267,7 @@ export class HookRunner {
...sanitizeEnvironment(process.env, this.config.sanitizationConfig), ...sanitizeEnvironment(process.env, this.config.sanitizationConfig),
GEMINI_PROJECT_DIR: input.cwd, GEMINI_PROJECT_DIR: input.cwd,
CLAUDE_PROJECT_DIR: input.cwd, // For compatibility CLAUDE_PROJECT_DIR: input.cwd, // For compatibility
...hookConfig.env,
}; };
const child = spawn( const child = spawn(
+1
View File
@@ -59,6 +59,7 @@ export interface CommandHookConfig {
description?: string; description?: string;
timeout?: number; timeout?: number;
source?: ConfigSource; source?: ConfigSource;
env?: Record<string, string>;
} }
export type HookConfig = CommandHookConfig; export type HookConfig = CommandHookConfig;