feat(cli): integrate conductor as a built-in extension

- Introduce support for bundled 'builtin' extensions in ExtensionManager
- Migrate conductor extension to packages/core/src/extensions/builtin/conductor
- Add automatic migration logic to cleanup manual conductor installations
- Make extension versioning optional for built-ins, defaulting to CLI version
- Update build and bundle scripts to include builtin extensions
- Enhance documentation to highlight conductor as a core feature
This commit is contained in:
Jerop Kipruto
2026-03-10 20:04:04 -04:00
parent fc51e50bc6
commit 92b9afbe21
26 changed files with 3077 additions and 29 deletions

View File

@@ -7,6 +7,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { stat } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type MergedSettings, SettingScope } from './settings.js';
@@ -458,8 +459,8 @@ Would you like to attempt to install via "git clone" instead?`,
newExtensionConfig.name,
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
previousExtensionConfig.version,
newExtensionConfig.version ?? 'unknown',
previousExtensionConfig.version ?? 'unknown',
installMetadata.type,
CoreToolCallStatus.Success,
),
@@ -483,7 +484,7 @@ Would you like to attempt to install via "git clone" instead?`,
newExtensionConfig.name,
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
newExtensionConfig.version ?? 'unknown',
installMetadata.type,
CoreToolCallStatus.Success,
),
@@ -520,8 +521,8 @@ Would you like to attempt to install via "git clone" instead?`,
config?.name ?? '',
hashValue(config?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
previousExtensionConfig.version,
newExtensionConfig?.version ?? 'unknown',
previousExtensionConfig.version ?? 'unknown',
installMetadata.type,
CoreToolCallStatus.Error,
),
@@ -533,7 +534,7 @@ Would you like to attempt to install via "git clone" instead?`,
newExtensionConfig?.name ?? '',
hashValue(newExtensionConfig?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
newExtensionConfig?.version ?? 'unknown',
installMetadata.type,
CoreToolCallStatus.Error,
),
@@ -636,6 +637,68 @@ Would you like to attempt to install via "git clone" instead?`,
(ext): ext is GeminiCLIExtension => ext !== null,
);
let builtinExtensionsDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'extensions',
'builtin',
);
if (!fs.existsSync(builtinExtensionsDir)) {
builtinExtensionsDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..',
'..',
'core',
'src',
'extensions',
'builtin',
);
}
if (!process.env['VITEST'] && fs.existsSync(builtinExtensionsDir)) {
const builtinSubdirs =
await fs.promises.readdir(builtinExtensionsDir);
const builtinPromises = builtinSubdirs.map((subdir) => {
const extensionDir = path.join(builtinExtensionsDir, subdir);
return this._buildExtension(extensionDir, true);
});
const builtinResolved = await Promise.all(builtinPromises);
for (const builtinExt of builtinResolved) {
if (builtinExt) {
const existingIdx = builtExtensions.findIndex(
(e) => e.name === builtinExt.name,
);
if (existingIdx !== -1) {
// If the user has a manually installed version of the builtin extension, we migrate them.
const manualExt = builtExtensions[existingIdx];
const storage = new ExtensionStorage(
manualExt.installMetadata?.type === 'link'
? manualExt.name
: path.basename(manualExt.path),
);
try {
await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
force: true,
});
debugLogger.debug(
`Migrated to built-in extension: ${builtinExt.name}. Removed manual installation.`,
);
} catch (e) {
debugLogger.warn(
`Failed to clean up manual installation of ${builtinExt.name}: ${getErrorMessage(e)}`,
);
}
builtExtensions[existingIdx] = builtinExt;
} else {
builtExtensions.push(builtinExt);
}
}
}
}
const seenNames = new Set<string>();
for (const ext of builtExtensions) {
if (seenNames.has(ext.name)) {
@@ -705,6 +768,7 @@ Would you like to attempt to install via "git clone" instead?`,
*/
private async _buildExtension(
extensionDir: string,
isBuiltin = false,
): Promise<GeminiCLIExtension | null> {
try {
const stats = await fs.promises.stat(extensionDir);
@@ -959,7 +1023,9 @@ Would you like to attempt to install via "git clone" instead?`,
return {
name: config.name,
version: config.version,
version:
config.version ??
(isBuiltin ? this.telemetryConfig.getClientVersion() : 'unknown'),
path: effectiveExtensionPath,
contextFiles,
installMetadata,
@@ -1028,9 +1094,9 @@ Would you like to attempt to install via "git clone" instead?`,
const configContent = await fs.promises.readFile(configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
if (!rawConfig.name || !rawConfig.version) {
if (!rawConfig.name) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`,
`Invalid configuration in ${configFilePath}: missing "name"`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion

View File

@@ -23,7 +23,7 @@ import type { ExtensionSetting } from './extensions/extensionSettings.js';
*/
export interface ExtensionConfig {
name: string;
version: string;
version?: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
excludeTools?: string[];