mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Show settings source in extensions lists (#16207)
This commit is contained in:
196
packages/cli/src/config/extension-manager-scope.test.ts
Normal file
196
packages/cli/src/config/extension-manager-scope.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
let currentTempHome = '';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => currentTempHome,
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionManager Settings Scope', () => {
|
||||
const extensionName = 'test-extension';
|
||||
let tempWorkspace: string;
|
||||
let extensionsDir: string;
|
||||
let extensionDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentTempHome = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
tempWorkspace = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
extensionsDir = path.join(currentTempHome, '.gemini', 'extensions');
|
||||
extensionDir = path.join(extensionsDir, extensionName);
|
||||
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
|
||||
// Create gemini-extension.json
|
||||
const extensionConfig = {
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'Test Setting',
|
||||
envVar: 'TEST_SETTING',
|
||||
description: 'A test setting',
|
||||
},
|
||||
],
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(extensionDir, 'gemini-extension.json'),
|
||||
JSON.stringify(extensionConfig),
|
||||
);
|
||||
|
||||
// Create install metadata
|
||||
const installMetadata = {
|
||||
source: extensionDir,
|
||||
type: 'local',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(extensionDir, 'install-metadata.json'),
|
||||
JSON.stringify(installMetadata),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up files if needed, or rely on temp dir cleanup
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should prioritize workspace settings over user settings and report correct scope', async () => {
|
||||
// 1. Set User Setting
|
||||
const userSettingsPath = path.join(extensionDir, '.env');
|
||||
fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value');
|
||||
|
||||
// 2. Set Workspace Setting
|
||||
const workspaceSettingsPath = path.join(tempWorkspace, '.env');
|
||||
fs.writeFileSync(workspaceSettingsPath, 'TEST_SETTING=workspace-value');
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspace,
|
||||
requestConsent: async () => true,
|
||||
requestSetting: async () => '',
|
||||
settings: {
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
} as Settings,
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === extensionName);
|
||||
|
||||
expect(extension).toBeDefined();
|
||||
|
||||
// Verify resolved settings
|
||||
const setting = extension?.resolvedSettings?.find(
|
||||
(s) => s.envVar === 'TEST_SETTING',
|
||||
);
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting?.value).toBe('workspace-value');
|
||||
expect(setting?.scope).toBe('workspace');
|
||||
expect(setting?.source).toBe(workspaceSettingsPath);
|
||||
|
||||
// Verify output string contains (Workspace - <path>)
|
||||
const output = extensionManager.toOutputString(extension!);
|
||||
expect(output).toContain(
|
||||
`Test Setting: workspace-value (Workspace - ${workspaceSettingsPath})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to user settings if workspace setting is missing', async () => {
|
||||
// 1. Set User Setting
|
||||
const userSettingsPath = path.join(extensionDir, '.env');
|
||||
fs.writeFileSync(userSettingsPath, 'TEST_SETTING=user-value');
|
||||
|
||||
// 2. No Workspace Setting
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspace,
|
||||
requestConsent: async () => true,
|
||||
requestSetting: async () => '',
|
||||
settings: {
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
} as Settings,
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === extensionName);
|
||||
|
||||
expect(extension).toBeDefined();
|
||||
|
||||
// Verify resolved settings
|
||||
const setting = extension?.resolvedSettings?.find(
|
||||
(s) => s.envVar === 'TEST_SETTING',
|
||||
);
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting?.value).toBe('user-value');
|
||||
expect(setting?.scope).toBe('user');
|
||||
expect(setting?.source?.endsWith(path.join(extensionName, '.env'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Verify output string contains (User - <path>)
|
||||
const output = extensionManager.toOutputString(extension!);
|
||||
expect(output).toContain(
|
||||
`Test Setting: user-value (User - ${userSettingsPath})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should report unset if neither is present', async () => {
|
||||
// No settings files
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspace,
|
||||
requestConsent: async () => true,
|
||||
requestSetting: async () => '',
|
||||
settings: {
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
} as Settings,
|
||||
});
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === extensionName);
|
||||
|
||||
expect(extension).toBeDefined();
|
||||
|
||||
// Verify resolved settings
|
||||
const setting = extension?.resolvedSettings?.find(
|
||||
(s) => s.envVar === 'TEST_SETTING',
|
||||
);
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting?.value).toBe('[not set]');
|
||||
expect(setting?.scope).toBeUndefined();
|
||||
|
||||
// Verify output string does not contain scope
|
||||
const output = extensionManager.toOutputString(extension!);
|
||||
expect(output).toContain('Test Setting: [not set]');
|
||||
expect(output).not.toContain('Test Setting: [not set] (User)');
|
||||
expect(output).not.toContain('Test Setting: [not set] (Workspace)');
|
||||
});
|
||||
});
|
||||
@@ -59,9 +59,12 @@ import {
|
||||
} from './extensions/variables.js';
|
||||
import {
|
||||
getEnvContents,
|
||||
getEnvFilePath,
|
||||
maybePromptForSettings,
|
||||
getMissingSettings,
|
||||
type ExtensionSetting,
|
||||
getScopedEnvContents,
|
||||
ExtensionSettingScope,
|
||||
} from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
import { getEnableHooks } from './settingsSchema.js';
|
||||
@@ -277,6 +280,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
previousSettings = await getEnvContents(
|
||||
previousExtensionConfig,
|
||||
extensionId,
|
||||
this.workspaceDir,
|
||||
);
|
||||
await this.uninstallExtension(newExtensionName, isUpdate);
|
||||
}
|
||||
@@ -303,6 +307,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
const missingSettings = await getMissingSettings(
|
||||
newExtensionConfig,
|
||||
extensionId,
|
||||
this.workspaceDir,
|
||||
);
|
||||
if (missingSettings.length > 0) {
|
||||
const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings
|
||||
@@ -518,16 +523,51 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
);
|
||||
}
|
||||
|
||||
const customEnv = await getEnvContents(
|
||||
const extensionId = getExtensionId(config, installMetadata);
|
||||
|
||||
const userSettings = await getScopedEnvContents(
|
||||
config,
|
||||
getExtensionId(config, installMetadata),
|
||||
extensionId,
|
||||
ExtensionSettingScope.USER,
|
||||
);
|
||||
const workspaceSettings = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
this.workspaceDir,
|
||||
);
|
||||
|
||||
const customEnv = { ...userSettings, ...workspaceSettings };
|
||||
config = resolveEnvVarsInObject(config, customEnv);
|
||||
|
||||
const resolvedSettings: ResolvedExtensionSetting[] = [];
|
||||
if (config.settings) {
|
||||
for (const setting of config.settings) {
|
||||
const value = customEnv[setting.envVar];
|
||||
let scope: 'user' | 'workspace' | undefined;
|
||||
let source: string | undefined;
|
||||
|
||||
// Note: strict check for undefined, as empty string is a valid value
|
||||
if (workspaceSettings[setting.envVar] !== undefined) {
|
||||
scope = 'workspace';
|
||||
if (setting.sensitive) {
|
||||
source = 'Keychain';
|
||||
} else {
|
||||
source = getEnvFilePath(
|
||||
config.name,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
this.workspaceDir,
|
||||
);
|
||||
}
|
||||
} else if (userSettings[setting.envVar] !== undefined) {
|
||||
scope = 'user';
|
||||
if (setting.sensitive) {
|
||||
source = 'Keychain';
|
||||
} else {
|
||||
source = getEnvFilePath(config.name, ExtensionSettingScope.USER);
|
||||
}
|
||||
}
|
||||
|
||||
resolvedSettings.push({
|
||||
name: setting.name,
|
||||
envVar: setting.envVar,
|
||||
@@ -538,6 +578,8 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
? '***'
|
||||
: value,
|
||||
sensitive: setting.sensitive ?? false,
|
||||
scope,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -754,7 +796,15 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
if (resolvedSettings && resolvedSettings.length > 0) {
|
||||
output += `\n Settings:`;
|
||||
resolvedSettings.forEach((setting) => {
|
||||
output += `\n ${setting.name}: ${setting.value}`;
|
||||
let scope = '';
|
||||
if (setting.scope) {
|
||||
scope = setting.scope === 'workspace' ? '(Workspace' : '(User';
|
||||
if (setting.source) {
|
||||
scope += ` - ${setting.source}`;
|
||||
}
|
||||
scope += ')';
|
||||
}
|
||||
output += `\n ${setting.name}: ${setting.value} ${scope}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
|
||||
@@ -529,6 +529,7 @@ describe('extensionSettings', () => {
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
expect(contents).toEqual({
|
||||
@@ -552,6 +553,7 @@ describe('extensionSettings', () => {
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
expect(contents).toEqual({
|
||||
@@ -596,7 +598,11 @@ describe('extensionSettings', () => {
|
||||
);
|
||||
await workspaceKeychain.setSecret('VAR2', 'workspace-secret2');
|
||||
|
||||
const contents = await getEnvContents(config, extensionId);
|
||||
const contents = await getEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
expect(contents).toEqual({
|
||||
VAR1: 'workspace-value1',
|
||||
@@ -636,6 +642,7 @@ describe('extensionSettings', () => {
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
@@ -652,6 +659,7 @@ describe('extensionSettings', () => {
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(tempWorkspaceDir, '.env');
|
||||
@@ -668,6 +676,7 @@ describe('extensionSettings', () => {
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
@@ -685,6 +694,7 @@ describe('extensionSettings', () => {
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const workspaceKeychain = new KeychainTokenStorage(
|
||||
@@ -710,6 +720,7 @@ describe('extensionSettings', () => {
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
// Read the .env file after update
|
||||
|
||||
@@ -33,20 +33,28 @@ const getKeychainStorageName = (
|
||||
extensionName: string,
|
||||
extensionId: string,
|
||||
scope: ExtensionSettingScope,
|
||||
workspaceDir?: string,
|
||||
): string => {
|
||||
const base = `Gemini CLI Extensions ${extensionName} ${extensionId}`;
|
||||
if (scope === ExtensionSettingScope.WORKSPACE) {
|
||||
return `${base} ${process.cwd()}`;
|
||||
if (!workspaceDir) {
|
||||
throw new Error('Workspace directory is required for workspace scope');
|
||||
}
|
||||
return `${base} ${workspaceDir}`;
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
const getEnvFilePath = (
|
||||
export const getEnvFilePath = (
|
||||
extensionName: string,
|
||||
scope: ExtensionSettingScope,
|
||||
workspaceDir?: string,
|
||||
): string => {
|
||||
if (scope === ExtensionSettingScope.WORKSPACE) {
|
||||
return path.join(process.cwd(), EXTENSION_SETTINGS_FILENAME);
|
||||
if (!workspaceDir) {
|
||||
throw new Error('Workspace directory is required for workspace scope');
|
||||
}
|
||||
return path.join(workspaceDir, EXTENSION_SETTINGS_FILENAME);
|
||||
}
|
||||
return new ExtensionStorage(extensionName).getEnvFilePath();
|
||||
};
|
||||
@@ -143,12 +151,13 @@ export async function getScopedEnvContents(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
scope: ExtensionSettingScope,
|
||||
workspaceDir?: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const { name: extensionName } = extensionConfig;
|
||||
const keychain = new KeychainTokenStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope),
|
||||
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
||||
);
|
||||
const envFilePath = getEnvFilePath(extensionName, scope);
|
||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||
let customEnv: Record<string, string> = {};
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
|
||||
@@ -171,6 +180,7 @@ export async function getScopedEnvContents(
|
||||
export async function getEnvContents(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
workspaceDir: string,
|
||||
): Promise<Record<string, string>> {
|
||||
if (!extensionConfig.settings || extensionConfig.settings.length === 0) {
|
||||
return Promise.resolve({});
|
||||
@@ -185,6 +195,7 @@ export async function getEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
workspaceDir,
|
||||
);
|
||||
|
||||
return { ...userSettings, ...workspaceSettings };
|
||||
@@ -196,6 +207,7 @@ export async function updateSetting(
|
||||
settingKey: string,
|
||||
requestSetting: (setting: ExtensionSetting) => Promise<string>,
|
||||
scope: ExtensionSettingScope,
|
||||
workspaceDir?: string,
|
||||
): Promise<void> {
|
||||
const { name: extensionName, settings } = extensionConfig;
|
||||
if (!settings || settings.length === 0) {
|
||||
@@ -214,7 +226,7 @@ export async function updateSetting(
|
||||
|
||||
const newValue = await requestSetting(settingToUpdate);
|
||||
const keychain = new KeychainTokenStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope),
|
||||
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
||||
);
|
||||
|
||||
if (settingToUpdate.sensitive) {
|
||||
@@ -224,7 +236,7 @@ export async function updateSetting(
|
||||
|
||||
// For non-sensitive settings, we need to read the existing .env file,
|
||||
// update the value, and write it back, preserving any other values.
|
||||
const envFilePath = getEnvFilePath(extensionName, scope);
|
||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||
let envContent = '';
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
envContent = await fs.readFile(envFilePath, 'utf-8');
|
||||
@@ -302,13 +314,18 @@ async function clearSettings(
|
||||
export async function getMissingSettings(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
workspaceDir: string,
|
||||
): Promise<ExtensionSetting[]> {
|
||||
const { settings } = extensionConfig;
|
||||
if (!settings || settings.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingSettings = await getEnvContents(extensionConfig, extensionId);
|
||||
const existingSettings = await getEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
workspaceDir,
|
||||
);
|
||||
const missingSettings: ExtensionSetting[] = [];
|
||||
|
||||
for (const setting of settings) {
|
||||
|
||||
@@ -154,7 +154,11 @@ describe('extensionUpdates', () => {
|
||||
);
|
||||
await userKeychain.setSecret('VAR2', 'val2');
|
||||
|
||||
const missing = await getMissingSettings(config, extensionId);
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -166,7 +170,11 @@ describe('extensionUpdates', () => {
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
const missing = await getMissingSettings(config, extensionId);
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0].name).toBe('s1');
|
||||
});
|
||||
@@ -181,7 +189,11 @@ describe('extensionUpdates', () => {
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
const missing = await getMissingSettings(config, extensionId);
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0].name).toBe('s2');
|
||||
});
|
||||
@@ -201,7 +213,11 @@ describe('extensionUpdates', () => {
|
||||
);
|
||||
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
|
||||
|
||||
const missing = await getMissingSettings(config, extensionId);
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,16 @@ describe('<ExtensionsList />', () => {
|
||||
value: '1000',
|
||||
envVar: 'MAX_TOKENS',
|
||||
sensitive: false,
|
||||
scope: 'user' as const,
|
||||
source: '/path/to/.env',
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
value: 'gemini-pro',
|
||||
envVar: 'MODEL',
|
||||
sensitive: false,
|
||||
scope: 'workspace' as const,
|
||||
source: 'Keychain',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -151,7 +161,8 @@ describe('<ExtensionsList />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('settings:');
|
||||
expect(output).toContain('- sensitiveApiKey: ***');
|
||||
expect(output).toContain('- maxTokens: 1000');
|
||||
expect(output).toContain('- maxTokens: 1000 (User - /path/to/.env)');
|
||||
expect(output).toContain('- model: gemini-pro (Workspace - Keychain)');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,15 @@ export const ExtensionsList: React.FC<ExtensionsList> = ({ extensions }) => {
|
||||
{ext.resolvedSettings.map((setting) => (
|
||||
<Text key={setting.name}>
|
||||
- {setting.name}: {setting.value}
|
||||
{setting.scope && (
|
||||
<Text color="gray">
|
||||
{' '}
|
||||
(
|
||||
{setting.scope.charAt(0).toUpperCase() +
|
||||
setting.scope.slice(1)}
|
||||
{setting.source ? ` - ${setting.source}` : ''})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -149,6 +149,8 @@ export interface ResolvedExtensionSetting {
|
||||
envVar: string;
|
||||
value: string;
|
||||
sensitive: boolean;
|
||||
scope?: 'user' | 'workspace';
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface CliHelpAgentSettings {
|
||||
|
||||
Reference in New Issue
Block a user