diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 45ca5a0d8a1..f9264cd0312 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -465,9 +465,6 @@ Would you like to attempt to install via "git clone" instead?`, ); } - /** - * Loads all installed extensions, should only be called once. - */ async loadExtensions(): Promise { if (this.loadedExtensions) { throw new Error('Extensions already loaded, only load extensions once.'); @@ -480,24 +477,66 @@ Would you like to attempt to install via "git clone" instead?`, const extensionsDir = ExtensionStorage.getUserExtensionsDir(); this.loadedExtensions = []; - if (!fs.existsSync(extensionsDir)) { + + let subdirs: string[]; + try { + subdirs = await fs.promises.readdir(extensionsDir); + } catch { + // Directory doesn't exist or can't be read return this.loadedExtensions; } - for (const subdir of fs.readdirSync(extensionsDir)) { + + const loadPromises = subdirs.map((subdir) => { const extensionDir = path.join(extensionsDir, subdir); - await this.loadExtension(extensionDir); + return this.loadExtension(extensionDir); + }); + + const results = await Promise.all(loadPromises); + + // Validate and collect successfully loaded extensions + const names = new Set(); + const extensions: GeminiCLIExtension[] = []; + + for (const result of results) { + if (result === null) continue; + + if (names.has(result.name)) { + debugLogger.error(`Duplicate extension name detected: ${result.name}`); + continue; + } + names.add(result.name); + extensions.push(result); + } + + // Single atomic update to shared state + this.loadedExtensions = extensions; + + // Start extensions sequentially to preserve ordering guarantees + for (const extension of this.loadedExtensions) { + await this.maybeStartExtension(extension); } + return this.loadedExtensions; } /** - * Adds `extension` to the list of extensions and starts it if appropriate. + * Loads an extension configuration from the given directory. + * + * Note: The caller is responsible for: + * - Adding the returned extension to the `this.loadedExtensions` array. + * - Calling `this.maybeStartExtension(extension)` to activate it. */ private async loadExtension( extensionDir: string, ): Promise { this.loadedExtensions ??= []; - if (!fs.statSync(extensionDir).isDirectory()) { + + try { + const stats = await fs.promises.stat(extensionDir); + if (!stats.isDirectory()) { + return null; + } + } catch { return null; } @@ -652,9 +691,7 @@ Would you like to attempt to install via "git clone" instead?`, skills, agents: agentLoadResult.agents, }; - this.loadedExtensions = [...this.loadedExtensions, extension]; - await this.maybeStartExtension(extension); return extension; } catch (e) { debugLogger.error( @@ -736,7 +773,6 @@ Would you like to attempt to install via "git clone" instead?`, return undefined; } - // Hydrate variables in the hooks configuration const hydratedHooks = recursivelyHydrateStrings( rawHooks.hooks as unknown as JsonObject, {