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:
@@ -87,3 +87,14 @@ You can install extensions using the `install` command. This command allows you
|
|||||||
|
|
||||||
- `source <url> positional argument`: The URL of a Git repository to install the extension from. The repository must contain a `gemini-extension.json` file in its root.
|
- `source <url> positional argument`: The URL of a Git repository to install the extension from. The repository must contain a `gemini-extension.json` file in its root.
|
||||||
- `--path <path>`: The path to a local directory to install as an extension. The directory must contain a `gemini-extension.json` file.
|
- `--path <path>`: The path to a local directory to install as an extension. The directory must contain a `gemini-extension.json` file.
|
||||||
|
|
||||||
|
# Variables
|
||||||
|
|
||||||
|
Gemini CLI extensions allow variable substitution in `gemini-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`.
|
||||||
|
|
||||||
|
**Supported variables:**
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
uninstallExtension,
|
uninstallExtension,
|
||||||
updateExtension,
|
updateExtension,
|
||||||
} from './extension.js';
|
} from './extension.js';
|
||||||
|
import { type MCPServerConfig } from '@google/gemini-cli-core';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { type SimpleGit, simpleGit } from 'simple-git';
|
import { type SimpleGit, simpleGit } from 'simple-git';
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ describe('loadExtensions', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include extension path in loaded extension', () => {
|
it('should include extension path in loaded extension', () => {
|
||||||
@@ -127,6 +129,37 @@ describe('loadExtensions', () => {
|
|||||||
path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'),
|
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', () => {
|
describe('annotateActiveExtensions', () => {
|
||||||
@@ -265,9 +298,7 @@ describe('installExtension', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mockedSimpleGit = simpleGit as vi.MockedFunction<typeof simpleGit>;
|
const mockedSimpleGit = simpleGit as vi.MockedFunction<typeof simpleGit>;
|
||||||
mockedSimpleGit.mockReturnValue({
|
mockedSimpleGit.mockReturnValue({ clone } as unknown as SimpleGit);
|
||||||
clone,
|
|
||||||
} as unknown as SimpleGit);
|
|
||||||
|
|
||||||
await installExtension({ source: gitUrl, type: 'git' });
|
await installExtension({ source: gitUrl, type: 'git' });
|
||||||
|
|
||||||
@@ -347,12 +378,13 @@ function createExtension(
|
|||||||
version: string,
|
version: string,
|
||||||
addContextFile = false,
|
addContextFile = false,
|
||||||
contextFileName?: string,
|
contextFileName?: string,
|
||||||
|
mcpServers?: Record<string, MCPServerConfig>,
|
||||||
): string {
|
): string {
|
||||||
const extDir = path.join(extensionsDir, name);
|
const extDir = path.join(extensionsDir, name);
|
||||||
fs.mkdirSync(extDir, { recursive: true });
|
fs.mkdirSync(extDir, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
|
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
|
||||||
JSON.stringify({ name, version, contextFileName }),
|
JSON.stringify({ name, version, contextFileName, mcpServers }),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (addContextFile) {
|
if (addContextFile) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import { simpleGit } from 'simple-git';
|
import { simpleGit } from 'simple-git';
|
||||||
|
import { recursivelyHydrateStrings } from './extensions/variables.js';
|
||||||
|
|
||||||
export const EXTENSIONS_DIRECTORY_NAME = '.gemini/extensions';
|
export const EXTENSIONS_DIRECTORY_NAME = '.gemini/extensions';
|
||||||
|
|
||||||
@@ -144,7 +145,11 @@ export function loadExtension(extensionDir: string): Extension | null {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
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) {
|
if (!config.name || !config.version) {
|
||||||
console.error(
|
console.error(
|
||||||
`Invalid extension config in ${configFilePath}: missing name or version.`,
|
`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