mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 05:55:17 -07:00
Load extension settings for hooks, agents, skills (#17245)
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user