/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import { type LlxprtExtension, type MCPServerConfig, } from '@vybestack/llxprt-code-core'; import { Storage } from '@vybestack/llxprt-code-settings'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { simpleGit } from 'simple-git'; import { SettingScope, loadSettings } from './settings.js'; import { getLoadSettings } from './extensionLoaderSettingsRef.js'; import { isWorkspaceTrusted, loadTrustedFolders, TrustLevel, } from './trustedFolders.js'; import { downloadFromGitHubRelease } from './extensions/github.js'; import type { LoadExtensionContext } from './extensions/variableSchema.js'; import chalk from 'chalk'; import { EXTENSION_ENABLEMENT_FILENAME, ExtensionEnablementManager, } from './extensions/extensionEnablement.js'; import type { ConfirmationRequest } from '../ui/types.js'; import { recursivelyHydrateStrings, type JsonValue, } from './extensions/variables.js'; import { maybeRequestConsentOrFail } from './extensions/extensionConsent.js'; import { validateHooks } from './extensions/hookSchema.js'; import type { Hooks } from './extensions/hookSchema.js'; export { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; // Root-aware management/resolver helpers extracted for module size and // single-responsibility-loop control flow. Public API is preserved via // re-export. import { loadExtensionByName, resolvePhysicalRegistrationDir, resolvePhysicalRegistrationDirByIdentifier, } from './extensions/rootAwareResolver.js'; export { loadExtensionByName, resolvePhysicalRegistrationDir, resolvePhysicalRegistrationDirByIdentifier, }; import { loadExtensionFromDir, loadInstallMetadataFromDir, } from './extensions/extensionLoader.js'; export const EXTENSIONS_DIRECTORY_NAME = '.llxprt/extensions'; export const COMPAT_EXTENSIONS_DIRECTORY_NAME = '.gemini/extensions'; export const EXTENSIONS_CONFIG_FILENAME = 'llxprt-extension.json'; export const EXTENSIONS_CONFIG_FILENAME_FALLBACK = 'gemini-extension.json'; export const INSTALL_METADATA_FILENAME = '.llxprt-extension-install.json'; export const INSTALL_METADATA_FILENAME_FALLBACK = '.gemini-extension-install.json'; /** * Extension setting definition from extension config */ export interface ExtensionSetting { name: string; envVar: string; description?: string; sensitive?: boolean; required?: boolean; } /** * Resolved extension setting with actual value */ export interface ResolvedExtensionSetting { name: string; envVar: string; value: string; description?: string; sensitive?: boolean; source?: 'user' | 'workspace' | 'default'; } /** * Extension definition as written to disk in gemini-extension.json files. * This should *not* be referenced outside of the logic for reading files. * If information is required for manipulating extensions (load, unload, update) * outside of the loading process that data needs to be stored on the * LlxprtExtension class defined in Core. */ export interface ExtensionConfig { name: string; version: string; mcpServers?: Record; contextFileName?: string | string[]; excludeTools?: string[]; hooks?: Hooks; settings?: ExtensionSetting[]; subagents?: Array<{ name: string; profile: string; systemPrompt: string; }>; } function isExtensionConfig(value: unknown): value is ExtensionConfig { return ( typeof value === 'object' && value !== null && typeof (value as { name?: unknown }).name === 'string' && typeof (value as { version?: unknown }).version === 'string' ); } function parseJsonValue(content: string): JsonValue { return JSON.parse(content) as JsonValue; } export interface ExtensionInstallMetadata { source: string; type: 'git' | 'local' | 'link' | 'github-release'; releaseTag?: string; // Only present for github-release installs. ref?: string; autoUpdate?: boolean; allowPreRelease?: boolean; } export interface ExtensionUpdateInfo { name: string; originalVersion: string; updatedVersion: string; } export class ExtensionStorage { private readonly extensionName: string; constructor(extensionName: string) { this.extensionName = extensionName; } getExtensionDir(): string { return path.join( ExtensionStorage.getUserExtensionsDir(), this.extensionName, ); } getConfigPath(): string { return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME); } static getSettingsPath(): string { return process.cwd(); } static getUserExtensionsDir(): string { return Storage.getUserExtensionsDir(); } static async createTmpDir(): Promise { return fs.promises.mkdtemp(path.join(os.tmpdir(), 'llxprt-extension')); } } export function getWorkspaceExtensions( workspaceDir: string, ): LlxprtExtension[] { // If the workspace dir is the user extensions dir, there are no workspace extensions. if (path.resolve(workspaceDir) === path.resolve(os.homedir())) { return []; } return loadExtensionsFromDir(workspaceDir); } export async function copyExtension( source: string, destination: string, ): Promise { await fs.promises.cp(source, destination, { recursive: true }); } export async function performWorkspaceExtensionMigration( extensions: LlxprtExtension[], requestConsent: (consent: string) => Promise, ): Promise { const failedInstallNames: string[] = []; for (const extension of extensions) { try { const installMetadata: ExtensionInstallMetadata = { source: extension.path, type: 'local', }; await installOrUpdateExtension(installMetadata, requestConsent); } catch { failedInstallNames.push(extension.name); } } return failedInstallNames; } export function loadExtensions( extensionEnablementManager: ExtensionEnablementManager, workspaceDir: string = process.cwd(), ): LlxprtExtension[] { const settings = loadSettings(workspaceDir).merged; // Admin-level extension disable enforcement if (settings.admin?.extensions?.enabled === false) { return []; } const folderTrusted = isWorkspaceTrusted(settings) ?? true; // Workspace-before-user precedence: trusted // workspace extensions are scanned first so the first-occurrence dedup // map keeps a workspace extension over a same-name user extension, and // a user extension over a same-name compat root extension. const allExtensions: LlxprtExtension[] = []; if (folderTrusted && settings.extensionManagement !== true) { allExtensions.push(...getWorkspaceExtensions(workspaceDir)); } allExtensions.push(...loadUserExtensions()); const uniqueExtensions = new Map(); for (const extension of allExtensions) { if ( !uniqueExtensions.has(extension.name) && extensionEnablementManager.isEnabled(extension.name, workspaceDir) ) { uniqueExtensions.set(extension.name, extension); } } return Array.from(uniqueExtensions.values()); } export function loadUserExtensions(): LlxprtExtension[] { // User extensions live under the canonical data-category dir (mirrors the // startup migration destination). The `.gemini/extensions` root under the // user home is a read-only compatibility source for migrated Gemini CLI // setups. Legacy `~/.llxprt/extensions` is NOT scanned here: startup // migration already copied it to the canonical data dir. const userRoots = [ ExtensionStorage.getUserExtensionsDir(), path.join(os.homedir(), COMPAT_EXTENSIONS_DIRECTORY_NAME), ]; return loadExtensionsFromRoots(userRoots, os.homedir()); } /** * Safely read directory entries, returning null if the directory does not * exist or cannot be enumerated (e.g. permission denied). Logs a diagnostic * for enumeration errors so the scan can continue with other roots. */ function readExtensionDirEntries(dir: string): string[] | null { if (!fs.existsSync(dir)) { return null; } try { return fs.readdirSync(dir); } catch (e) { globalThis.console.warn( `Warning: could not enumerate extensions directory ${dir}: ${e}`, ); return null; } } export function loadExtensionsFromDir( dir: string, workspaceDir: string = dir, ): LlxprtExtension[] { // LLxprt-first precedence, NO dedup: returns every extension so callers // apply precedence-based dedup by name. return loadExtensionsFromRoots( [ new Storage(dir).getExtensionsDir(), path.join(dir, COMPAT_EXTENSIONS_DIRECTORY_NAME), ], workspaceDir, /* dedupe */ false, ); } /** * Scan explicit extension roots in order. When `dedupe` is true (default, used * for global user-extension scanning), each name is emitted once in * root-precedence order. When false (workspace scanning), all extensions are * returned for caller-side dedup. */ export function loadExtensionsFromRoots( roots: readonly string[], workspaceDir: string, dedupe = true, ): LlxprtExtension[] { const out: LlxprtExtension[] = []; const seen = new Set(); for (const extensionsDir of roots) { const entries = readExtensionDirEntries(extensionsDir); if (entries === null) continue; for (const subdir of entries) { if (subdir === EXTENSION_ENABLEMENT_FILENAME) continue; const extension = loadExtension({ extensionDir: path.join(extensionsDir, subdir), workspaceDir, }); if (extension !== null && (!dedupe || !seen.has(extension.name))) { seen.add(extension.name); out.push(extension); } } } return out; } const extensionLoaderDeps = { configFileName: EXTENSIONS_CONFIG_FILENAME, fallbackConfigFileName: EXTENSIONS_CONFIG_FILENAME_FALLBACK, installMetadataFileName: INSTALL_METADATA_FILENAME, fallbackInstallMetadataFileName: INSTALL_METADATA_FILENAME_FALLBACK, loadSettings: (workspaceDir: string) => getLoadSettings()(workspaceDir), validateName, reportError: (message: string) => globalThis.console.error(message), reportWarning: (message: string) => globalThis.console.warn(message), }; export function loadExtension( context: LoadExtensionContext, ): LlxprtExtension | null { return loadExtensionFromDir(context, extensionLoaderDeps); } /** * Resolves extension settings with provenance information. * This async function loads settings from both user and workspace scopes, * determines which scope provides the effective value, and returns settings * enriched with source metadata. * * @param extensionName - The extension name * @param extensionPath - The extension directory path * @param settings - The extension's declared settings from manifest * @returns Promise resolving to array of resolved settings with source metadata */ export async function resolveExtensionSettingsWithSource( extensionName: string, extensionPath: string, settings: ExtensionSetting[], ): Promise { if (settings.length === 0) { return []; } const { getScopedEnvContents, ExtensionSettingScope } = await import( './extensions/settingsIntegration.js' ); const userValues = await getScopedEnvContents( extensionName, extensionPath, ExtensionSettingScope.USER, ); const workspaceValues = await getScopedEnvContents( extensionName, extensionPath, ExtensionSettingScope.WORKSPACE, ); return settings.map((setting) => { const workspaceValue = workspaceValues[setting.envVar]; const userValue = userValues[setting.envVar]; let value: string; let source: 'user' | 'workspace' | 'default'; // Workspace overrides user when workspace value is explicitly set if (workspaceValue !== undefined && workspaceValue !== '') { value = setting.sensitive === true ? '[value stored in keychain]' : workspaceValue; source = 'workspace'; } else if (userValue !== undefined && userValue !== '') { value = setting.sensitive === true ? '[value stored in keychain]' : userValue; source = 'user'; } else { value = '[not set]'; source = 'default'; } return { name: setting.name, envVar: setting.envVar, value, description: setting.description, sensitive: setting.sensitive ?? false, source, }; }); } export function loadInstallMetadata( extensionDir: string, ): ExtensionInstallMetadata | undefined { return loadInstallMetadataFromDir( extensionDir, INSTALL_METADATA_FILENAME, INSTALL_METADATA_FILENAME_FALLBACK, ); } export function annotateActiveExtensions( extensions: LlxprtExtension[], workspaceDir: string, manager: ExtensionEnablementManager, ): LlxprtExtension[] { manager.validateExtensionOverrides(extensions); return extensions.map((extension) => ({ ...extension, isActive: manager.isEnabled(extension.name, workspaceDir), })); } /** * Clones a Git repository to a specified local path. * @param gitUrl The Git URL to clone. * @param destination The destination path to clone the repository to. */ async function cloneFromGit( gitUrl: string, destination: string, ): Promise { try { // Follow-up (#1569, chrstnb): Download the archive instead to avoid unnecessary .git info. await simpleGit().clone(gitUrl, destination, ['--depth', '1']); } catch (error) { throw new Error(`Failed to clone Git repository from ${gitUrl}`, { cause: error, }); } } /** * Requests consent from the user to perform an action, by reading a Y/n * character from stdin. * * This should not be called from interactive mode as it will break the CLI. * * @param consentDescription The description of the thing they will be consenting to. * @returns boolean, whether they consented or not. */ export async function requestConsentNonInteractive( consentDescription: string, ): Promise { globalThis.console.info(consentDescription); const result = await promptForConsentNonInteractive( 'Do you want to continue? [Y/n]: ', ); return result; } /** * Requests consent from the user to perform an action, in interactive mode. * * This should not be called from non-interactive mode as it will not work. * * @param consentDescription The description of the thing they will be consenting to. * @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI. * @returns boolean, whether they consented or not. */ export async function requestConsentInteractive( consentDescription: string, addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, ): Promise { return promptForConsentInteractive( consentDescription + '\n\nDo you want to continue?', addExtensionUpdateConfirmationRequest, ); } /** * Asks users a prompt and awaits for a y/n response on stdin. * * This should not be called from interactive mode as it will break the CLI. * * @param prompt A yes/no prompt to ask the user * @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter. */ async function promptForConsentNonInteractive( prompt: string, ): Promise { const readline = await import('node:readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(prompt, (answer) => { rl.close(); resolve(['y', ''].includes(answer.trim().toLowerCase())); }); }); } /** * Asks users an interactive yes/no prompt. * * This should not be called from non-interactive mode as it will break the CLI. * * @param prompt A markdown prompt to ask the user * @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request. * @returns Whether or not the user answers yes. */ async function promptForConsentInteractive( prompt: string, addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, ): Promise { return new Promise((resolve) => { addExtensionUpdateConfirmationRequest({ prompt, onConfirm: (resolvedConfirmed) => { resolve(resolvedConfirmed); }, }); }); } /** * Infers installation metadata from a source string. * Validates the source and determines whether it's a git URL or local path. */ export async function inferInstallMetadata( source: string, args: { ref?: string; autoUpdate?: boolean; allowPreRelease?: boolean; } = {}, ): Promise { const { ref, autoUpdate, allowPreRelease } = args; // Check if source is a URL (git, http, https, sso) const isUrl = source.startsWith('http://') || source.startsWith('https://') || source.startsWith('git@') || source.startsWith('sso://'); if (isUrl) { // Git-based installation return { source, type: 'git', ref, autoUpdate, allowPreRelease, }; } // Local path - verify it exists // Preserve old truthy behavior: reject only non-empty string ref or autoUpdate === true if (typeof ref === 'string' && ref.length > 0) { throw new Error( 'The --ref and --autoUpdate flags are only applicable for git-based installations.', ); } if (autoUpdate === true || allowPreRelease === true) { throw new Error( 'The --ref, --autoUpdate, and --allow-pre-release flags are only applicable for git-based installations.', ); } try { await fs.promises.stat(source); return { source, type: 'local', }; } catch { throw new Error(`Install source not found: ${source}`); } } interface ExtensionInstallPlan { localSourcePath: string; tempDir?: string; } function ensureRemoteExtensionAllowed( installMetadata: ExtensionInstallMetadata, settings: ReturnType['merged'], ): void { if ( (installMetadata.type === 'git' || installMetadata.type === 'github-release') && (settings.security as { blockGitExtensions?: boolean } | undefined) ?.blockGitExtensions === true ) { throw new Error( 'Installing extensions from remote sources is disallowed by your current settings.', ); } } async function ensureWorkspaceTrustedForInstall( settings: ReturnType['merged'], cwd: string, requestConsent: (consent: string) => Promise, ): Promise { if (isWorkspaceTrusted(settings) !== false) { return; } const didTrust = await requestConsent( `The current workspace at "${cwd}" is not trusted. Do you want to trust this workspace to install extensions?`, ); if (!didTrust) { throw new Error( `Could not install extension because the current workspace at ${cwd} is not trusted.`, ); } const trustedFolders = loadTrustedFolders(); trustedFolders.setValue(cwd, TrustLevel.TRUST_FOLDER); } function normalizeInstallSource( installMetadata: ExtensionInstallMetadata, ): void { if ( !path.isAbsolute(installMetadata.source) && (installMetadata.type === 'local' || installMetadata.type === 'link') ) { installMetadata.source = path.resolve( process.cwd(), installMetadata.source, ); } } async function createExtensionInstallPlan( installMetadata: ExtensionInstallMetadata, ): Promise { if (installMetadata.type === 'git') { const tempDir = await ExtensionStorage.createTmpDir(); await cloneFromGit(installMetadata.source, tempDir); return { localSourcePath: tempDir, tempDir }; } if (installMetadata.type === 'github-release') { const tempDir = await ExtensionStorage.createTmpDir(); const result = await downloadFromGitHubRelease(installMetadata, tempDir); installMetadata.ref = result.tagName; return { localSourcePath: tempDir, tempDir }; } return { localSourcePath: installMetadata.source }; } async function loadValidatedExtensionConfig( localSourcePath: string, source: string, cwd: string, ): Promise { const newExtensionConfig = await loadExtensionConfig({ extensionDir: localSourcePath, workspaceDir: cwd, }); if (newExtensionConfig == null) { throw new Error( `Invalid extension at ${source}. Please make sure it has a valid ${EXTENSIONS_CONFIG_FILENAME} or ${EXTENSIONS_CONFIG_FILENAME_FALLBACK} file.`, ); } return newExtensionConfig; } async function warnMissingExtensionSettings( extensionConfig: ExtensionConfig, destinationPath: string, settings: ReturnType['merged'], ): Promise { if ( extensionConfig.settings == null || extensionConfig.settings.length === 0 || (settings.experimental?.extensionConfig ?? false) === false ) { return; } const { getMissingSettings } = await import( './extensions/settingsIntegration.js' ); const missingSettings = await getMissingSettings( extensionConfig.name, destinationPath, ); if (missingSettings.length === 0) { return; } const settingNames = missingSettings .map((setting) => setting.name) .join(', '); globalThis.console.warn( `Extension "${extensionConfig.name}" has missing settings: ${settingNames}. Please run "llxprt extensions config ${extensionConfig.name}" to configure them.`, ); } function ensureExtensionNotAlreadyInstalled( newExtensionName: string, isUpdate: boolean, ): void { if (isUpdate) { return; } const installedExtensions = loadUserExtensions(); if ( installedExtensions.some((installed) => installed.name === newExtensionName) ) { throw new Error( `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, ); } } async function installExtensionFiles( installMetadata: ExtensionInstallMetadata, localSourcePath: string, destinationPath: string, ): Promise { await fs.promises.mkdir(destinationPath, { recursive: true }); if ( installMetadata.type === 'local' || installMetadata.type === 'git' || installMetadata.type === 'github-release' ) { await copyExtension(localSourcePath, destinationPath); } const metadataString = JSON.stringify(installMetadata, null, 2); const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME); await fs.promises.writeFile(metadataPath, metadataString); } export async function installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, requestConsent: (consent: string) => Promise, cwd: string = process.cwd(), previousExtensionConfig?: ExtensionConfig, ): Promise { const isUpdate = previousExtensionConfig != null; const settings = loadSettings(cwd).merged; ensureRemoteExtensionAllowed(installMetadata, settings); await ensureWorkspaceTrustedForInstall(settings, cwd, requestConsent); await fs.promises.mkdir(ExtensionStorage.getUserExtensionsDir(), { recursive: true, }); normalizeInstallSource(installMetadata); const plan = await createExtensionInstallPlan(installMetadata); try { const newExtensionConfig = await loadValidatedExtensionConfig( plan.localSourcePath, installMetadata.source, cwd, ); const newExtensionName = newExtensionConfig.name; const extensionStorage = new ExtensionStorage(newExtensionName); const destinationPath = extensionStorage.getExtensionDir(); await warnMissingExtensionSettings( newExtensionConfig, destinationPath, settings, ); ensureExtensionNotAlreadyInstalled(newExtensionName, isUpdate); await maybeRequestConsentOrFail( newExtensionConfig, requestConsent, previousExtensionConfig, ); if (isUpdate) { await uninstallExtension(newExtensionName, true, cwd); } await installExtensionFiles( installMetadata, plan.localSourcePath, destinationPath, ); return newExtensionName; } finally { if (plan.tempDir !== undefined) { await fs.promises.rm(plan.tempDir, { recursive: true, force: true }); } } } /** * Validates an extension name contains only alphanumeric characters and dashes. * @param name The extension name to validate. * @throws Error if the name contains invalid characters. */ export function validateName(name: string): void { if (!/^[a-zA-Z0-9-]+$/.test(name)) { throw new Error( `Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`, ); } } export async function loadExtensionConfig( context: LoadExtensionContext, ): Promise { const { extensionDir, workspaceDir } = context; // Try llxprt-extension.json first, then fall back to gemini-extension.json let configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); if (!fs.existsSync(configFilePath)) { configFilePath = path.join( extensionDir, EXTENSIONS_CONFIG_FILENAME_FALLBACK, ); } if (!fs.existsSync(configFilePath)) { globalThis.console.warn( `Extension config file not found at ${extensionDir}. Expected ${EXTENSIONS_CONFIG_FILENAME} or ${EXTENSIONS_CONFIG_FILENAME_FALLBACK}.`, ); return null; } try { const configContent = fs.readFileSync(configFilePath, 'utf-8'); const rawConfig = parseJsonValue(configContent); if (!isExtensionConfig(rawConfig)) { globalThis.console.warn( `Invalid extension configuration in ${configFilePath}: missing name or version`, ); return null; } const installDir = new ExtensionStorage(rawConfig.name).getExtensionDir(); const hydratedConfig = recursivelyHydrateStrings(rawConfig, { extensionPath: installDir, workspacePath: workspaceDir, '/': path.sep, pathSeparator: path.sep, }); if (!isExtensionConfig(hydratedConfig)) { globalThis.console.warn( `Invalid extension configuration in ${configFilePath}: missing name or version`, ); return null; } const config = hydratedConfig; validateName(config.name); // Validate hooks if present if (config.hooks !== undefined) { config.hooks = validateHooks(config.hooks); } return config; } catch (e) { // Re-throw validation errors so installExtension() can report them if ( e instanceof Error && (e.message.includes('Invalid extension name') || e.message.includes('Invalid configuration') || e.message.includes('Hook name')) ) { throw e; } globalThis.console.warn( `Failed to load extension config from ${configFilePath}: ${e instanceof Error ? e.message : String(e)}`, ); return null; } } export async function uninstallExtension( extensionIdentifier: string, isUpdate: boolean, cwd: string = process.cwd(), ): Promise { // Resolve the physical registration directory directly by identifier. // This searches all extension roots (.llxprt/extensions and // .gemini/extensions), trying exact case-sensitive name or source matches // before an unambiguous case-insensitive fallback. This preserves physical // registration identity rather than deleting a different registration. const physicalDir = resolvePhysicalRegistrationDirByIdentifier( extensionIdentifier, cwd, ); if (physicalDir === null) { throw new Error( `Extension "${extensionIdentifier}" not found. Run llxprt extensions list to see available extensions.`, ); } // Load the extension from the resolved physical dir to get its name for // enablement cleanup. const loaded = loadExtension({ extensionDir: physicalDir, workspaceDir: cwd, }); if (loaded === null) { throw new Error( `Extension "${extensionIdentifier}" not found. Run llxprt extensions list to see available extensions.`, ); } if (!isUpdate) { const manager = new ExtensionEnablementManager( ExtensionStorage.getUserExtensionsDir(), [loaded.name], ); manager.remove(loaded.name); } return fs.promises.rm(physicalDir, { recursive: true, force: true, }); } export function toOutputString( extension: LlxprtExtension, workspaceDir: string, ): string { const manager = new ExtensionEnablementManager( ExtensionStorage.getUserExtensionsDir(), ); const userEnabled = manager.isEnabled(extension.name, os.homedir()); const workspaceEnabled = manager.isEnabled(extension.name, workspaceDir); const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); let output = `${status} ${extension.name} (${extension.version})`; output += `\n Path: ${extension.path}`; if (extension.installMetadata) { output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`; } output += `\n Enabled (User): ${userEnabled}`; output += `\n Enabled (Workspace): ${workspaceEnabled}`; if (extension.contextFiles.length > 0) { output += `\n Context files:`; extension.contextFiles.forEach((contextFile) => { output += `\n ${contextFile}`; }); } if (extension.mcpServers) { output += `\n MCP servers:`; Object.keys(extension.mcpServers).forEach((key) => { output += `\n ${key}`; }); } if (extension.excludeTools) { output += `\n Excluded tools:`; extension.excludeTools.forEach((tool) => { output += `\n ${tool}`; }); } return output; } export function disableExtension( name: string, scope: SettingScope, cwd: string = process.cwd(), ) { if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) { throw new Error('System and SystemDefaults scopes are not supported.'); } const extension = loadExtensionByName(name, cwd); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } const manager = new ExtensionEnablementManager( ExtensionStorage.getUserExtensionsDir(), ); const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir(); manager.disable(name, true, scopePath); } export function enableExtension( name: string, scope: SettingScope, cwd: string = process.cwd(), ) { if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) { throw new Error('System and SystemDefaults scopes are not supported.'); } const extension = loadExtensionByName(name, cwd); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } const manager = new ExtensionEnablementManager( ExtensionStorage.getUserExtensionsDir(), ); const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir(); manager.enable(name, true, scopePath); }