mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
[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>
This commit is contained in:
@@ -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<typeof simpleGit>;
|
||||
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, MCPServerConfig>,
|
||||
): 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) {
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
30
packages/cli/src/config/extensions/variableSchema.ts
Normal file
30
packages/cli/src/config/extensions/variableSchema.ts
Normal file
@@ -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;
|
||||
18
packages/cli/src/config/extensions/variables.test.ts
Normal file
18
packages/cli/src/config/extensions/variables.test.ts
Normal file
@@ -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!');
|
||||
});
|
||||
});
|
||||
65
packages/cli/src/config/extensions/variables.ts
Normal file
65
packages/cli/src/config/extensions/variables.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user