Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -11,6 +11,8 @@ import { listCommand } from './extensions/list.js';
import { updateCommand } from './extensions/update.js';
import { disableCommand } from './extensions/disable.js';
import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -23,6 +25,8 @@ export const extensionsCommand: CommandModule = {
.command(updateCommand)
.command(disableCommand)
.command(enableCommand)
.command(linkCommand)
.command(newCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {

View File

@@ -11,12 +11,16 @@ import { getErrorMessage } from '../../utils/errors.js';
interface DisableArgs {
name: string;
scope: SettingScope;
scope?: string;
}
export async function handleDisable(args: DisableArgs) {
export function handleDisable(args: DisableArgs) {
try {
disableExtension(args.name, args.scope);
if (args.scope?.toLowerCase() === 'workspace') {
disableExtension(args.name, SettingScope.Workspace);
} else {
disableExtension(args.name, SettingScope.User);
}
console.log(
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
);
@@ -39,13 +43,28 @@ export const disableCommand: CommandModule = {
describe: 'The scope to disable the extenison in.',
type: 'string',
default: SettingScope.User,
choices: [SettingScope.User, SettingScope.Workspace],
})
.check((_argv) => true),
handler: async (argv) => {
await handleDisable({
.check((argv) => {
if (
argv.scope &&
!Object.values(SettingScope)
.map((s) => s.toLowerCase())
.includes((argv.scope as string).toLowerCase())
) {
throw new Error(
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`,
);
}
return true;
}),
handler: (argv) => {
handleDisable({
name: argv['name'] as string,
scope: argv['scope'] as SettingScope,
scope: argv['scope'] as string,
});
},
};

View File

@@ -11,15 +11,16 @@ import { SettingScope } from '../../config/settings.js';
interface EnableArgs {
name: string;
scope?: SettingScope;
scope?: string;
}
export async function handleEnable(args: EnableArgs) {
export function handleEnable(args: EnableArgs) {
try {
const scopes = args.scope
? [args.scope]
: [SettingScope.User, SettingScope.Workspace];
enableExtension(args.name, scopes);
if (args.scope?.toLowerCase() === 'workspace') {
enableExtension(args.name, SettingScope.Workspace);
} else {
enableExtension(args.name, SettingScope.User);
}
if (args.scope) {
console.log(
`Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
@@ -35,7 +36,7 @@ export async function handleEnable(args: EnableArgs) {
}
export const enableCommand: CommandModule = {
command: 'disable [--scope] <name>',
command: 'enable [--scope] <name>',
describe: 'Enables an extension.',
builder: (yargs) =>
yargs
@@ -47,13 +48,28 @@ export const enableCommand: CommandModule = {
describe:
'The scope to enable the extenison in. If not set, will be enabled in all scopes.',
type: 'string',
choices: [SettingScope.User, SettingScope.Workspace],
})
.check((_argv) => true),
handler: async (argv) => {
await handleEnable({
.check((argv) => {
if (
argv.scope &&
!Object.values(SettingScope)
.map((s) => s.toLowerCase())
.includes((argv.scope as string).toLowerCase())
) {
throw new Error(
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`,
);
}
return true;
}),
handler: (argv) => {
handleEnable({
name: argv['name'] as string,
scope: argv['scope'] as SettingScope,
scope: argv['scope'] as string,
});
},
};

View File

@@ -0,0 +1,8 @@
# Ink Library Screen Reader Guidance
When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience.
## General Principles
Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users.
Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on <Box> and <Text> to provide semantic meaning to screen readers.

View File

@@ -0,0 +1,4 @@
{
"name": "context-example",
"version": "1.0.0"
}

View File

@@ -0,0 +1,6 @@
prompt = """
Please summarize the findings for the pattern `{{args}}`.
Search Results:
!{grep -r {{args}} .}
"""

View File

@@ -0,0 +1,4 @@
{
"name": "custom-commands",
"version": "1.0.0"
}

View File

@@ -0,0 +1,5 @@
{
"name": "excludeTools",
"version": "1.0.0",
"excludeTools": ["run_shell_command(rm -rf)"]
}

View File

@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({
name: 'prompt-server',
version: '1.0.0',
});
server.registerTool(
'fetch_posts',
{
description: 'Fetches a list of posts from a public API.',
inputSchema: z.object({}).shape,
},
async () => {
const apiResponse = await fetch(
'https://jsonplaceholder.typicode.com/posts',
);
const posts = await apiResponse.json();
const response = { posts: posts.slice(0, 5) };
return {
content: [
{
type: 'text',
text: JSON.stringify(response),
},
],
};
},
);
server.registerPrompt(
'poem-writer',
{
title: 'Poem Writer',
description: 'Write a nice haiku',
argsSchema: { title: z.string(), mood: z.string().optional() },
},
({ title, mood }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `,
},
},
],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);

View File

@@ -0,0 +1,18 @@
{
"name": "mcp-server-example",
"version": "1.0.0",
"description": "Example MCP Server for Gemini CLI Extension",
"type": "module",
"main": "example.js",
"scripts": {
"build": "tsc"
},
"devDependencies": {
"typescript": "~5.4.5",
"@types/node": "^20.11.25"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"zod": "^3.22.4"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "mcp-server-example",
"version": "1.0.0",
"mcpServers": {
"nodeServer": {
"command": "node",
"args": ["${extensionPath}${/}dist${/}example.js"],
"cwd": "${extensionPath}"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["example.ts"]
}

View File

@@ -4,10 +4,30 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { installCommand } from './install.js';
import { describe, it, expect, vi, type MockInstance } from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
const mockInstallExtension = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockStat = vi.hoisted(() => vi.fn());
vi.mock('../../config/extension.js', () => ({
installExtension: mockInstallExtension,
requestConsentNonInteractive: mockRequestConsentNonInteractive,
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
vi.mock('node:fs/promises', () => ({
stat: mockStat,
default: {
stat: mockStat,
},
}));
describe('extensions install command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([])
@@ -15,17 +35,109 @@ describe('extensions install command', () => {
.command(installCommand)
.fail(false);
expect(() => validationParser.parse('install')).toThrow(
'Either a git URL --source or a --path must be provided.',
'Not enough non-option arguments: got 0, need at least 1',
);
});
});
describe('handleInstall', () => {
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let processSpy: MockInstance;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log');
consoleErrorSpy = vi.spyOn(console, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
});
afterEach(() => {
mockInstallExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
vi.resetAllMocks();
});
it('should install an extension from a http source', async () => {
mockInstallExtension.mockResolvedValue('http-extension');
await handleInstall({
source: 'http://google.com',
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "http-extension" installed successfully and enabled.',
);
});
it('should fail if both git source and local path are provided', () => {
const validationParser = yargs([])
.locale('en')
.command(installCommand)
.fail(false);
expect(() =>
validationParser.parse('install --source some-url --path /some/path'),
).toThrow('Arguments source and path are mutually exclusive');
it('should install an extension from a https source', async () => {
mockInstallExtension.mockResolvedValue('https-extension');
await handleInstall({
source: 'https://google.com',
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "https-extension" installed successfully and enabled.',
);
});
it('should install an extension from a git source', async () => {
mockInstallExtension.mockResolvedValue('git-extension');
await handleInstall({
source: 'git@some-url',
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "git-extension" installed successfully and enabled.',
);
});
it('throws an error from an unknown source', async () => {
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
await handleInstall({
source: 'test://google.com',
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Install source not found.');
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should install an extension from a sso source', async () => {
mockInstallExtension.mockResolvedValue('sso-extension');
await handleInstall({
source: 'sso://google.com',
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "sso-extension" installed successfully and enabled.',
);
});
it('should install an extension from a local path', async () => {
mockInstallExtension.mockResolvedValue('local-extension');
mockStat.mockResolvedValue({});
await handleInstall({
source: '/some/path',
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "local-extension" installed successfully and enabled.',
);
});
it('should throw an error if install extension fails', async () => {
mockInstallExtension.mockRejectedValue(
new Error('Install extension failed'),
);
await handleInstall({ source: 'git@some-url' });
expect(consoleErrorSpy).toHaveBeenCalledWith('Install extension failed');
expect(processSpy).toHaveBeenCalledWith(1);
});
});

View File

@@ -7,26 +7,56 @@
import type { CommandModule } from 'yargs';
import {
installExtension,
type ExtensionInstallMetadata,
requestConsentNonInteractive,
} from '../../config/extension.js';
import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../utils/errors.js';
import { stat } from 'node:fs/promises';
interface InstallArgs {
source?: string;
path?: string;
source: string;
ref?: string;
autoUpdate?: boolean;
}
export async function handleInstall(args: InstallArgs) {
try {
const installMetadata: ExtensionInstallMetadata = {
source: (args.source || args.path) as string,
type: args.source ? 'git' : 'local',
};
const extensionName = await installExtension(installMetadata);
console.log(
`Extension "${extensionName}" installed successfully and enabled.`,
let installMetadata: ExtensionInstallMetadata;
const { source } = args;
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
installMetadata = {
source,
type: 'git',
ref: args.ref,
autoUpdate: args.autoUpdate,
};
} else {
if (args.ref || args.autoUpdate) {
throw new Error(
'--ref and --auto-update are not applicable for local extensions.',
);
}
try {
await stat(source);
installMetadata = {
source,
type: 'local',
};
} catch {
throw new Error('Install source not found.');
}
}
const name = await installExtension(
installMetadata,
requestConsentNonInteractive,
);
console.log(`Extension "${name}" installed successfully and enabled.`);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
@@ -34,31 +64,34 @@ export async function handleInstall(args: InstallArgs) {
}
export const installCommand: CommandModule = {
command: 'install [--source | --path ]',
describe: 'Installs an extension from a git repository or a local path.',
command: 'install <source>',
describe: 'Installs an extension from a git repository URL or a local path.',
builder: (yargs) =>
yargs
.option('source', {
describe: 'The git URL of the extension to install.',
.positional('source', {
describe: 'The github URL or local path of the extension to install.',
type: 'string',
demandOption: true,
})
.option('ref', {
describe: 'The git ref to install from.',
type: 'string',
})
.option('path', {
describe: 'Path to a local extension directory.',
type: 'string',
.option('auto-update', {
describe: 'Enable auto-update for this extension.',
type: 'boolean',
})
.conflicts('source', 'path')
.check((argv) => {
if (!argv.source && !argv.path) {
throw new Error(
'Either a git URL --source or a --path must be provided.',
);
if (!argv.source) {
throw new Error('The source argument must be provided.');
}
return true;
}),
handler: async (argv) => {
await handleInstall({
source: argv['source'] as string | undefined,
path: argv['path'] as string | undefined,
source: argv['source'] as string,
ref: argv['ref'] as string | undefined,
autoUpdate: argv['auto-update'] as boolean | undefined,
});
},
};

View File

@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import {
installExtension,
requestConsentNonInteractive,
} from '../../config/extension.js';
import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../utils/errors.js';
interface InstallArgs {
path: string;
}
export async function handleLink(args: InstallArgs) {
try {
const installMetadata: ExtensionInstallMetadata = {
source: args.path,
type: 'link',
};
const extensionName = await installExtension(
installMetadata,
requestConsentNonInteractive,
);
console.log(
`Extension "${extensionName}" linked successfully and enabled.`,
);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
}
}
export const linkCommand: CommandModule = {
command: 'link <path>',
describe:
'Links an extension from a local path. Updates made to the local path will always be reflected.',
builder: (yargs) =>
yargs
.positional('path', {
describe: 'The name of the extension to link.',
type: 'string',
})
.check((_) => true),
handler: async (argv) => {
await handleLink({
path: argv['path'] as string,
});
},
};

View File

@@ -17,7 +17,7 @@ export async function handleList() {
}
console.log(
extensions
.map((extension, _): string => toOutputString(extension))
.map((extension, _): string => toOutputString(extension, process.cwd()))
.join('\n\n'),
);
} catch (error) {

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { newCommand } from './new.js';
import yargs from 'yargs';
import * as fsPromises from 'node:fs/promises';
import path from 'node:path';
vi.mock('node:fs/promises');
const mockedFs = vi.mocked(fsPromises);
describe('extensions new command', () => {
beforeEach(() => {
vi.resetAllMocks();
const fakeFiles = [
{ name: 'context', isDirectory: () => true },
{ name: 'custom-commands', isDirectory: () => true },
{ name: 'mcp-server', isDirectory: () => true },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockedFs.readdir.mockResolvedValue(fakeFiles as any);
});
it('should fail if no path is provided', async () => {
const parser = yargs([]).command(newCommand).fail(false).locale('en');
await expect(parser.parseAsync('new')).rejects.toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
it('should create directory when no template is provided', async () => {
mockedFs.access.mockRejectedValue(new Error('ENOENT'));
mockedFs.mkdir.mockResolvedValue(undefined);
const parser = yargs([]).command(newCommand).fail(false);
await parser.parseAsync('new /some/path');
expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', {
recursive: true,
});
expect(mockedFs.cp).not.toHaveBeenCalled();
});
it('should create directory and copy files when path does not exist', async () => {
mockedFs.access.mockRejectedValue(new Error('ENOENT'));
mockedFs.mkdir.mockResolvedValue(undefined);
mockedFs.cp.mockResolvedValue(undefined);
const parser = yargs([]).command(newCommand).fail(false);
await parser.parseAsync('new /some/path context');
expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', {
recursive: true,
});
expect(mockedFs.cp).toHaveBeenCalledWith(
expect.stringContaining(path.normalize('context/context')),
path.normalize('/some/path/context'),
{ recursive: true },
);
expect(mockedFs.cp).toHaveBeenCalledWith(
expect.stringContaining(path.normalize('context/custom-commands')),
path.normalize('/some/path/custom-commands'),
{ recursive: true },
);
expect(mockedFs.cp).toHaveBeenCalledWith(
expect.stringContaining(path.normalize('context/mcp-server')),
path.normalize('/some/path/mcp-server'),
{ recursive: true },
);
});
it('should throw an error if the path already exists', async () => {
mockedFs.access.mockResolvedValue(undefined);
const parser = yargs([]).command(newCommand).fail(false);
await expect(parser.parseAsync('new /some/path context')).rejects.toThrow(
'Path already exists: /some/path',
);
expect(mockedFs.mkdir).not.toHaveBeenCalled();
expect(mockedFs.cp).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { access, cp, mkdir, readdir, writeFile } from 'node:fs/promises';
import { join, dirname, basename } from 'node:path';
import type { CommandModule } from 'yargs';
import { fileURLToPath } from 'node:url';
import { getErrorMessage } from '../../utils/errors.js';
interface NewArgs {
path: string;
template?: string;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const EXAMPLES_PATH = join(__dirname, 'examples');
async function pathExists(path: string) {
try {
await access(path);
return true;
} catch (_e) {
return false;
}
}
async function createDirectory(path: string) {
if (await pathExists(path)) {
throw new Error(`Path already exists: ${path}`);
}
await mkdir(path, { recursive: true });
}
async function copyDirectory(template: string, path: string) {
await createDirectory(path);
const examplePath = join(EXAMPLES_PATH, template);
const entries = await readdir(examplePath, { withFileTypes: true });
for (const entry of entries) {
const srcPath = join(examplePath, entry.name);
const destPath = join(path, entry.name);
await cp(srcPath, destPath, { recursive: true });
}
}
async function handleNew(args: NewArgs) {
try {
if (args.template) {
await copyDirectory(args.template, args.path);
console.log(
`Successfully created new extension from template "${args.template}" at ${args.path}.`,
);
} else {
await createDirectory(args.path);
const extensionName = basename(args.path);
const manifest = {
name: extensionName,
version: '1.0.0',
};
await writeFile(
join(args.path, 'qwen-extension.json'),
JSON.stringify(manifest, null, 2),
);
console.log(`Successfully created new extension at ${args.path}.`);
}
console.log(
`You can install this using "qwen extensions link ${args.path}" to test it out.`,
);
} catch (error) {
console.error(getErrorMessage(error));
throw error;
}
}
async function getBoilerplateChoices() {
const entries = await readdir(EXAMPLES_PATH, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
}
export const newCommand: CommandModule = {
command: 'new <path> [template]',
describe: 'Create a new extension from a boilerplate example.',
builder: async (yargs) => {
const choices = await getBoilerplateChoices();
return yargs
.positional('path', {
describe: 'The path to create the extension in.',
type: 'string',
})
.positional('template', {
describe: 'The boilerplate template to use.',
type: 'string',
choices,
});
},
handler: async (args) => {
await handleNew({
path: args['path'] as string,
template: args['template'] as string | undefined,
});
},
};

View File

@@ -11,9 +11,9 @@ import yargs from 'yargs';
describe('extensions uninstall command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([])
.locale('en')
.command(uninstallCommand)
.fail(false);
.fail(false)
.locale('en');
expect(() => validationParser.parse('uninstall')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);

View File

@@ -9,7 +9,7 @@ import { uninstallExtension } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
interface UninstallArgs {
name: string;
name: string; // can be extension name or source URL.
}
export async function handleUninstall(args: UninstallArgs) {
@@ -28,7 +28,7 @@ export const uninstallCommand: CommandModule = {
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to uninstall.',
describe: 'The name or source path of the extension to uninstall.',
type: 'string',
})
.check((argv) => {

View File

@@ -5,43 +5,147 @@
*/
import type { CommandModule } from 'yargs';
import { updateExtension } from '../../config/extension.js';
import {
loadExtensions,
annotateActiveExtensions,
ExtensionStorage,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
updateAllUpdatableExtensions,
type ExtensionUpdateInfo,
checkForAllExtensionUpdates,
updateExtension,
} from '../../config/extensions/update.js';
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
interface UpdateArgs {
name: string;
name?: string;
all?: boolean;
}
const updateOutput = (info: ExtensionUpdateInfo) =>
`Extension "${info.name}" successfully updated: ${info.originalVersion}${info.updatedVersion}.`;
export async function handleUpdate(args: UpdateArgs) {
try {
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
const updatedExtensionInfo = await updateExtension(args.name);
if (!updatedExtensionInfo) {
console.log(`Extension "${args.name}" failed to update.`);
return;
const workingDir = process.cwd();
const extensionEnablementManager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
// Force enable named extensions, otherwise we will only update the enabled
// ones.
args.name ? [args.name] : [],
);
const allExtensions = loadExtensions(extensionEnablementManager);
const extensions = annotateActiveExtensions(
allExtensions,
workingDir,
extensionEnablementManager,
);
if (args.name) {
try {
const extension = extensions.find(
(extension) => extension.name === args.name,
);
if (!extension) {
console.log(`Extension "${args.name}" not found.`);
return;
}
let updateState: ExtensionUpdateState | undefined;
if (!extension.installMetadata) {
console.log(
`Unable to install extension "${args.name}" due to missing install metadata`,
);
return;
}
await checkForExtensionUpdate(extension, (newState) => {
updateState = newState;
});
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
console.log(`Extension "${args.name}" is already up to date.`);
return;
}
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
const updatedExtensionInfo = (await updateExtension(
extension,
workingDir,
requestConsentNonInteractive,
updateState,
() => {},
))!;
if (
updatedExtensionInfo.originalVersion !==
updatedExtensionInfo.updatedVersion
) {
console.log(
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion}${updatedExtensionInfo.updatedVersion}.`,
);
} else {
console.log(`Extension "${args.name}" is already up to date.`);
}
} catch (error) {
console.error(getErrorMessage(error));
}
}
if (args.all) {
try {
const extensionState = new Map();
await checkForAllExtensionUpdates(extensions, (action) => {
if (action.type === 'SET_STATE') {
extensionState.set(action.payload.name, {
status: action.payload.state,
processed: true, // No need to process as we will force the update.
});
}
});
let updateInfos = await updateAllUpdatableExtensions(
workingDir,
requestConsentNonInteractive,
extensions,
extensionState,
() => {},
);
updateInfos = updateInfos.filter(
(info) => info.originalVersion !== info.updatedVersion,
);
if (updateInfos.length === 0) {
console.log('No extensions to update.');
return;
}
console.log(updateInfos.map((info) => updateOutput(info)).join('\n'));
} catch (error) {
console.error(getErrorMessage(error));
}
console.log(
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion}${updatedExtensionInfo.updatedVersion}.`,
);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
}
}
export const updateCommand: CommandModule = {
command: 'update <name>',
describe: 'Updates an extension.',
command: 'update [<name>] [--all]',
describe:
'Updates all extensions or a named extension to the latest version.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to update.',
type: 'string',
})
.check((_argv) => true),
.option('all', {
describe: 'Update all extensions.',
type: 'boolean',
})
.conflicts('name', 'all')
.check((argv) => {
if (!argv.all && !argv.name) {
throw new Error('Either an extension name or --all must be provided');
}
return true;
}),
handler: async (argv) => {
await handleUpdate({
name: argv['name'] as string,
name: argv['name'] as string | undefined,
all: argv['all'] as boolean | undefined,
});
},
};

View File

@@ -13,6 +13,16 @@ vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
}));
vi.mock('os', () => {
const homedir = vi.fn(() => '/home/user');
return {
default: {
homedir,
},
homedir,
};
});
vi.mock('../../config/settings.js', async () => {
const actual = await vi.importActual('../../config/settings.js');
return {
@@ -26,15 +36,20 @@ const mockedLoadSettings = loadSettings as vi.Mock;
describe('mcp add command', () => {
let parser: yargs.Argv;
let mockSetValue: vi.Mock;
let mockConsoleError: vi.Mock;
beforeEach(() => {
vi.resetAllMocks();
const yargsInstance = yargs([]).command(addCommand);
parser = yargsInstance;
mockSetValue = vi.fn();
mockConsoleError = vi.fn();
vi.spyOn(console, 'error').mockImplementation(mockConsoleError);
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: {} }),
setValue: mockSetValue,
workspace: { path: '/path/to/project' },
user: { path: '/home/user' },
});
});
@@ -119,4 +134,218 @@ describe('mcp add command', () => {
},
);
});
describe('when handling scope and directory', () => {
const serverName = 'test-server';
const command = 'echo';
const setupMocks = (cwd: string, workspacePath: string) => {
vi.spyOn(process, 'cwd').mockReturnValue(cwd);
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: {} }),
setValue: mockSetValue,
workspace: { path: workspacePath },
user: { path: '/home/user' },
});
};
describe('when in a project directory', () => {
beforeEach(() => {
setupMocks('/path/to/project', '/path/to/project');
});
it('should use project scope by default', async () => {
await parser.parseAsync(`add ${serverName} ${command}`);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
expect.any(Object),
);
});
it('should use project scope when --scope=project is used', async () => {
await parser.parseAsync(`add --scope project ${serverName} ${command}`);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
expect.any(Object),
);
});
it('should use user scope when --scope=user is used', async () => {
await parser.parseAsync(`add --scope user ${serverName} ${command}`);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.User,
'mcpServers',
expect.any(Object),
);
});
});
describe('when in a subdirectory of a project', () => {
beforeEach(() => {
setupMocks('/path/to/project/subdir', '/path/to/project');
});
it('should use project scope by default', async () => {
await parser.parseAsync(`add ${serverName} ${command}`);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
expect.any(Object),
);
});
});
describe('when in the home directory', () => {
beforeEach(() => {
setupMocks('/home/user', '/home/user');
});
it('should show an error by default', async () => {
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {
throw new Error('process.exit called');
}) as (code?: number) => never);
await expect(
parser.parseAsync(`add ${serverName} ${command}`),
).rejects.toThrow('process.exit called');
expect(mockConsoleError).toHaveBeenCalledWith(
'Error: Please use --scope user to edit settings in the home directory.',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
expect(mockSetValue).not.toHaveBeenCalled();
});
it('should show an error when --scope=project is used explicitly', async () => {
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {
throw new Error('process.exit called');
}) as (code?: number) => never);
await expect(
parser.parseAsync(`add --scope project ${serverName} ${command}`),
).rejects.toThrow('process.exit called');
expect(mockConsoleError).toHaveBeenCalledWith(
'Error: Please use --scope user to edit settings in the home directory.',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
expect(mockSetValue).not.toHaveBeenCalled();
});
it('should use user scope when --scope=user is used', async () => {
await parser.parseAsync(`add --scope user ${serverName} ${command}`);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.User,
'mcpServers',
expect.any(Object),
);
expect(mockConsoleError).not.toHaveBeenCalled();
});
});
describe('when in a subdirectory of home (not a project)', () => {
beforeEach(() => {
setupMocks('/home/user/some/dir', '/home/user/some/dir');
});
it('should use project scope by default', async () => {
await parser.parseAsync(`add ${serverName} ${command}`);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
expect.any(Object),
);
});
it('should write to the WORKSPACE scope, not the USER scope', async () => {
await parser.parseAsync(`add my-new-server echo`);
// We expect setValue to be called once.
expect(mockSetValue).toHaveBeenCalledTimes(1);
// We get the scope that setValue was called with.
const calledScope = mockSetValue.mock.calls[0][0];
// We assert that the scope was Workspace, not User.
expect(calledScope).toBe(SettingScope.Workspace);
});
});
describe('when outside of home (not a project)', () => {
beforeEach(() => {
setupMocks('/tmp/foo', '/tmp/foo');
});
it('should use project scope by default', async () => {
await parser.parseAsync(`add ${serverName} ${command}`);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
expect.any(Object),
);
});
});
});
describe('when updating an existing server', () => {
const serverName = 'existing-server';
const initialCommand = 'echo old';
const updatedCommand = 'echo';
const updatedArgs = ['new'];
beforeEach(() => {
mockedLoadSettings.mockReturnValue({
forScope: () => ({
settings: {
mcpServers: {
[serverName]: {
command: initialCommand,
},
},
},
}),
setValue: mockSetValue,
workspace: { path: '/path/to/project' },
user: { path: '/home/user' },
});
});
it('should update the existing server in the project scope', async () => {
await parser.parseAsync(
`add ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`,
);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
expect.objectContaining({
[serverName]: expect.objectContaining({
command: updatedCommand,
args: updatedArgs,
}),
}),
);
});
it('should update the existing server in the user scope', async () => {
await parser.parseAsync(
`add --scope user ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`,
);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.User,
'mcpServers',
expect.objectContaining({
[serverName]: expect.objectContaining({
command: updatedCommand,
args: updatedArgs,
}),
}),
);
});
});
});

View File

@@ -36,9 +36,19 @@ async function addMcpServer(
includeTools,
excludeTools,
} = options;
const settings = loadSettings(process.cwd());
const inHome = settings.workspace.path === settings.user.path;
if (scope === 'project' && inHome) {
console.error(
'Error: Please use --scope user to edit settings in the home directory.',
);
process.exit(1);
}
const settingsScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const settings = loadSettings(process.cwd());
let newServer: Partial<MCPServerConfig> = {};

View File

@@ -7,7 +7,7 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings } from '../../config/settings.js';
import { loadExtensions } from '../../config/extension.js';
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
import { createTransport } from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -16,6 +16,9 @@ vi.mock('../../config/settings.js', () => ({
}));
vi.mock('../../config/extension.js', () => ({
loadExtensions: vi.fn(),
ExtensionStorage: {
getUserExtensionsDir: vi.fn(),
},
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
createTransport: vi.fn(),
@@ -29,11 +32,12 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
getWorkspaceSettingsPath: () => '/tmp/qwen/workspace-settings.json',
getProjectTempDir: () => '/test/home/.qwen/tmp/mocked_hash',
})),
GEMINI_CONFIG_DIR: '.qwen',
QWEN_CONFIG_DIR: '.qwen',
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
}));
vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedExtensionStorage = ExtensionStorage as vi.Mock;
const mockedLoadSettings = loadSettings as vi.Mock;
const mockedLoadExtensions = loadExtensions as vi.Mock;
const mockedCreateTransport = createTransport as vi.Mock;
@@ -69,6 +73,9 @@ describe('mcp list command', () => {
MockedClient.mockImplementation(() => mockClient);
mockedCreateTransport.mockResolvedValue(mockTransport);
mockedLoadExtensions.mockReturnValue([]);
mockedExtensionStorage.getUserExtensionsDir.mockReturnValue(
'/mocked/extensions/dir',
);
});
afterEach(() => {

View File

@@ -10,7 +10,8 @@ import { loadSettings } from '../../config/settings.js';
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
import { MCPServerStatus, createTransport } from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { loadExtensions } from '../../config/extension.js';
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
@@ -20,8 +21,10 @@ const RESET_COLOR = '\u001b[0m';
async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig>
> {
const settings = loadSettings(process.cwd());
const extensions = loadExtensions(process.cwd());
const settings = loadSettings();
const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach(

View File

@@ -17,7 +17,7 @@ async function removeMcpServer(
const { scope } = options;
const settingsScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const settings = loadSettings(process.cwd());
const settings = loadSettings();
const existingSettings = settings.forScope(settingsScope).settings;
const mcpServers = existingSettings.mcpServers || {};