mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-21 11:30:38 -07:00
Add gemini extensions link command (#7241)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
This commit is contained in:
@@ -217,6 +217,36 @@ describe('loadExtensions', () => {
|
||||
expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd);
|
||||
});
|
||||
|
||||
it('should load a linked extension correctly', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
contextFileName: 'context.md',
|
||||
});
|
||||
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
|
||||
|
||||
const extensionName = await installExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'link',
|
||||
});
|
||||
expect(extensionName).toEqual('my-linked-extension');
|
||||
const extensions = loadExtensions(tempHomeDir);
|
||||
expect(extensions).toHaveLength(1);
|
||||
|
||||
const linkedExt = extensions[0];
|
||||
expect(linkedExt.config.name).toBe('my-linked-extension');
|
||||
|
||||
expect(linkedExt.path).toBe(sourceExtDir);
|
||||
expect(linkedExt.installMetadata).toEqual({
|
||||
source: sourceExtDir,
|
||||
type: 'link',
|
||||
});
|
||||
expect(linkedExt.contextFiles).toEqual([
|
||||
path.join(sourceExtDir, 'context.md'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should resolve environment variables in extension configuration', () => {
|
||||
process.env.TEST_API_KEY = 'test-api-key-123';
|
||||
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
|
||||
@@ -402,12 +432,12 @@ describe('installExtension', () => {
|
||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
|
||||
vi.mocked(execSync).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should install an extension from a local path', async () => {
|
||||
@@ -487,6 +517,31 @@ describe('installExtension', () => {
|
||||
});
|
||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should install a linked extension', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
await installExtension({ source: sourceExtDir, type: 'link' });
|
||||
|
||||
expect(fs.existsSync(targetExtDir)).toBe(true);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
||||
expect(metadata).toEqual({
|
||||
source: sourceExtDir,
|
||||
type: 'link',
|
||||
});
|
||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallExtension', () => {
|
||||
@@ -701,7 +756,9 @@ function createExtension({
|
||||
}
|
||||
|
||||
if (contextFileName) {
|
||||
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
|
||||
const contextPath = path.join(extDir, contextFileName);
|
||||
fs.mkdirSync(path.dirname(contextPath), { recursive: true });
|
||||
fs.writeFileSync(contextPath, 'context');
|
||||
}
|
||||
return extDir;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ExtensionConfig {
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
source: string;
|
||||
type: 'git' | 'local';
|
||||
type: 'git' | 'local' | 'link';
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
@@ -175,10 +175,20 @@ export function loadExtension(extensionDir: string): Extension | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
const installMetadata = loadInstallMetadata(extensionDir);
|
||||
let effectiveExtensionPath = extensionDir;
|
||||
|
||||
if (installMetadata?.type === 'link') {
|
||||
effectiveExtensionPath = installMetadata.source;
|
||||
}
|
||||
|
||||
const configFilePath = path.join(
|
||||
effectiveExtensionPath,
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
);
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
console.error(
|
||||
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
|
||||
`Warning: extension directory ${effectiveExtensionPath} does not contain a config file ${configFilePath}.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -200,14 +210,16 @@ export function loadExtension(extensionDir: string): Extension | null {
|
||||
config = resolveEnvVarsInObject(config);
|
||||
|
||||
const contextFiles = getContextFileNames(config)
|
||||
.map((contextFileName) => path.join(extensionDir, contextFileName))
|
||||
.map((contextFileName) =>
|
||||
path.join(effectiveExtensionPath, contextFileName),
|
||||
)
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
return {
|
||||
path: extensionDir,
|
||||
path: effectiveExtensionPath,
|
||||
config,
|
||||
contextFiles,
|
||||
installMetadata: loadInstallMetadata(extensionDir),
|
||||
installMetadata,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
@@ -343,32 +355,38 @@ export async function installExtension(
|
||||
|
||||
// Convert relative paths to absolute paths for the metadata file.
|
||||
if (
|
||||
installMetadata.type === 'local' &&
|
||||
!path.isAbsolute(installMetadata.source)
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let localSourcePath: string;
|
||||
let tempDir: string | undefined;
|
||||
let newExtensionName: string | undefined;
|
||||
|
||||
if (installMetadata.type === 'git') {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
await cloneFromGit(installMetadata.source, tempDir);
|
||||
localSourcePath = tempDir;
|
||||
} else {
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'link'
|
||||
) {
|
||||
localSourcePath = installMetadata.source;
|
||||
} else {
|
||||
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||
}
|
||||
let newExtensionName: string | undefined;
|
||||
|
||||
try {
|
||||
const newExtension = loadExtension(localSourcePath);
|
||||
if (!newExtension) {
|
||||
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
if (!newExtensionConfig) {
|
||||
throw new Error(
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ~/.gemini/extensions/{ExtensionConfig.name}.
|
||||
newExtensionName = newExtension.config.name;
|
||||
newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
|
||||
@@ -383,7 +401,11 @@ export async function installExtension(
|
||||
);
|
||||
}
|
||||
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
||||
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
|
||||
@@ -397,6 +419,29 @@ export async function installExtension(
|
||||
return newExtensionName;
|
||||
}
|
||||
|
||||
async function loadExtensionConfig(
|
||||
extensionDir: string,
|
||||
): Promise<ExtensionConfig | null> {
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
||||
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
|
||||
extensionPath: extensionDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
}) as unknown as ExtensionConfig;
|
||||
if (!config.name || !config.version) {
|
||||
return null;
|
||||
}
|
||||
return config;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallExtension(
|
||||
extensionName: string,
|
||||
cwd: string = process.cwd(),
|
||||
@@ -425,7 +470,7 @@ export function toOutputString(extension: Extension): string {
|
||||
let output = `${extension.config.name} (${extension.config.version})`;
|
||||
output += `\n Path: ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n Source: ${extension.installMetadata.source}`;
|
||||
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||
}
|
||||
if (extension.contextFiles.length > 0) {
|
||||
output += `\n Context files:`;
|
||||
@@ -471,14 +516,21 @@ export async function updateExtension(
|
||||
if (!extension.installMetadata) {
|
||||
throw new Error(`Extension ${extension.config.name} cannot be updated.`);
|
||||
}
|
||||
if (extension.installMetadata.type === 'link') {
|
||||
throw new Error(`Extension is linked so does not need to be updated`);
|
||||
}
|
||||
const originalVersion = extension.config.version;
|
||||
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
await copyExtension(extension.path, tempDir);
|
||||
await uninstallExtension(extension.config.name, cwd);
|
||||
await installExtension(extension.installMetadata, cwd);
|
||||
|
||||
const updatedExtension = loadExtension(extension.path);
|
||||
const updatedExtensionStorage = new ExtensionStorage(extension.config.name);
|
||||
const updatedExtension = loadExtension(
|
||||
updatedExtensionStorage.getExtensionDir(),
|
||||
);
|
||||
if (!updatedExtension) {
|
||||
throw new Error('Updated extension not found after installation.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user